mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
3673 lines
166 KiB
Python
3673 lines
166 KiB
Python
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import Optional, List, Dict, Any, Callable, Tuple
|
||
import pandas as pd
|
||
import math
|
||
import random
|
||
import re
|
||
import datetime
|
||
import os
|
||
import csv
|
||
import textwrap
|
||
|
||
from . import builder_constants as bc
|
||
from . import builder_utils as bu
|
||
|
||
# Attempt to use a fast fuzzy library; fall back gracefully
|
||
|
||
|
||
try:
|
||
from rapidfuzz import process as rf_process, fuzz as rf_fuzz
|
||
_FUZZ_BACKEND = "rapidfuzz"
|
||
except ImportError:
|
||
try:
|
||
from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz
|
||
_FUZZ_BACKEND = "fuzzywuzzy"
|
||
except ImportError:
|
||
_FUZZ_BACKEND = "difflib"
|
||
|
||
if _FUZZ_BACKEND == "rapidfuzz":
|
||
def _full_ratio(a: str, b: str) -> float:
|
||
return rf_fuzz.ratio(a, b)
|
||
def _top_matches(query: str, choices: List[str], limit: int):
|
||
return [(name, int(score)) for name, score, _ in rf_process.extract(query, choices, limit=limit)]
|
||
elif _FUZZ_BACKEND == "fuzzywuzzy":
|
||
def _full_ratio(a: str, b: str) -> float:
|
||
return fw_fuzz.ratio(a, b)
|
||
def _top_matches(query: str, choices: List[str], limit: int):
|
||
return fw_process.extract(query, choices, limit=limit)
|
||
else:
|
||
# Very basic fallback (difflib)
|
||
from difflib import SequenceMatcher, get_close_matches
|
||
def _full_ratio(a: str, b: str) -> float:
|
||
return SequenceMatcher(None, a.lower(), b.lower()).ratio() * 100
|
||
def _top_matches(query: str, choices: List[str], limit: int):
|
||
close = get_close_matches(query, choices, n=limit, cutoff=0.0)
|
||
scored = [(c, int(_full_ratio(query, c))) for c in close]
|
||
if len(scored) < limit:
|
||
remaining = [c for c in choices if c not in close]
|
||
extra = sorted(
|
||
((c, int(_full_ratio(query, c))) for c in remaining),
|
||
key=lambda x: x[1],
|
||
reverse=True
|
||
)[: limit - len(scored)]
|
||
scored.extend(extra)
|
||
return scored
|
||
|
||
EXACT_NAME_THRESHOLD = 80
|
||
FIRST_WORD_THRESHOLD = 75
|
||
MAX_PRESENTED_CHOICES = 5
|
||
|
||
# ---------------------------
|
||
# Deck Power Bracket (Deck Building Step 1)
|
||
# ---------------------------
|
||
|
||
@dataclass(frozen=True)
|
||
class BracketDefinition:
|
||
level: int
|
||
name: str
|
||
short_desc: str
|
||
long_desc: str
|
||
limits: Dict[str, Optional[int]] # None = unlimited
|
||
|
||
BRACKET_DEFINITIONS: List[BracketDefinition] = [
|
||
BracketDefinition(
|
||
1,
|
||
"Exhibition",
|
||
"Ultra-casual / novelty; long games; focus on fun.",
|
||
("Throw down with your ultra‑casual deck. Winning isn't primary—show off something unusual. "
|
||
"Games go long and end slowly."),
|
||
{
|
||
"game_changers": 0,
|
||
"mass_land_denial": 0,
|
||
"extra_turns": 0,
|
||
"tutors_nonland": 3,
|
||
"two_card_combos": 0
|
||
}
|
||
),
|
||
BracketDefinition(
|
||
2,
|
||
"Core",
|
||
"Precon baseline; splashy turns; 9+ turn games.",
|
||
("Average modern precon: tuned engines & splashy turns, some pet/theme cards, usually longer games."),
|
||
{
|
||
"game_changers": 0,
|
||
"mass_land_denial": 0,
|
||
"extra_turns": 3,
|
||
"tutors_nonland": 3,
|
||
"two_card_combos": 0
|
||
}
|
||
),
|
||
BracketDefinition(
|
||
3,
|
||
"Upgraded",
|
||
"Refined beyond precon; faster; selective power.",
|
||
("Carefully selected cards; may include up to three Game Changers. Avoids cheap fast infinite two‑card combos."),
|
||
{
|
||
"game_changers": 3,
|
||
"mass_land_denial": 0,
|
||
"extra_turns": 3,
|
||
"tutors_nonland": None,
|
||
"two_card_combos": 0
|
||
}
|
||
),
|
||
BracketDefinition(
|
||
4,
|
||
"Optimized",
|
||
"High power, explosive, not meta-focused.",
|
||
("Strong, explosive builds; any number of powerful effects, tutors, combos, and denial."),
|
||
{
|
||
"game_changers": None,
|
||
"mass_land_denial": None,
|
||
"extra_turns": None,
|
||
"tutors_nonland": None,
|
||
"two_card_combos": None
|
||
}
|
||
),
|
||
BracketDefinition(
|
||
5,
|
||
"cEDH",
|
||
"Competitive, meta-driven mindset.",
|
||
("Metagame/tournament mindset; precision choices; winning prioritized over expression."),
|
||
{
|
||
"game_changers": None,
|
||
"mass_land_denial": None,
|
||
"extra_turns": None,
|
||
"tutors_nonland": None,
|
||
"two_card_combos": None
|
||
}
|
||
),
|
||
]
|
||
|
||
|
||
@dataclass
|
||
class DeckBuilder:
|
||
# Commander core selection state
|
||
commander_name: str = ""
|
||
commander_row: Optional[pd.Series] = None
|
||
commander_tags: List[str] = field(default_factory=list)
|
||
|
||
# Tag prioritization
|
||
primary_tag: Optional[str] = None
|
||
secondary_tag: Optional[str] = None
|
||
tertiary_tag: Optional[str] = None
|
||
selected_tags: List[str] = field(default_factory=list)
|
||
|
||
# Future deck config placeholders
|
||
color_identity: List[str] = field(default_factory=list) # raw list of color letters e.g. ['B','G']
|
||
color_identity_key: Optional[str] = None # canonical key form e.g. 'B, G'
|
||
color_identity_full: Optional[str] = None # human readable e.g. 'Golgari: Black/Green'
|
||
files_to_load: List[str] = field(default_factory=list) # csv file stems to load
|
||
synergy_profile: Dict[str, Any] = field(default_factory=dict)
|
||
deck_goal: Optional[str] = None
|
||
|
||
# Aggregated commander info (scalar fields)
|
||
commander_dict: Dict[str, Any] = field(default_factory=dict)
|
||
|
||
# Power bracket state (Deck Building Step 1)
|
||
bracket_level: Optional[int] = None
|
||
bracket_name: Optional[str] = None
|
||
bracket_limits: Dict[str, Optional[int]] = field(default_factory=dict)
|
||
bracket_definition: Optional[BracketDefinition] = None
|
||
|
||
# Cached data
|
||
_commander_df: Optional[pd.DataFrame] = None
|
||
_combined_cards_df: Optional[pd.DataFrame] = None
|
||
_full_cards_df: Optional[pd.DataFrame] = None # immutable snapshot of original combined pool
|
||
|
||
# Deck library (cards added so far) mapping name->record
|
||
card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||
# Tag tracking: counts of unique cards per tag (not per copy)
|
||
tag_counts: Dict[str,int] = field(default_factory=dict)
|
||
# Internal map name -> set of tags used for uniqueness checks
|
||
_card_name_tags_index: Dict[str,set] = 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))
|
||
output_func: Callable[[str], None] = field(default=lambda msg: print(msg))
|
||
# Deterministic random support
|
||
seed: Optional[int] = None
|
||
_rng: Any = field(default=None, repr=False)
|
||
|
||
# ---------------------------
|
||
# High-level Orchestration
|
||
# ---------------------------
|
||
def build_deck_full(self) -> None:
|
||
"""Run the full interactive deck building pipeline and export deck CSV.
|
||
|
||
Steps:
|
||
1. Commander selection & tag prioritization
|
||
2. Power bracket & ideal composition inputs
|
||
3. Land building steps (1-8)
|
||
4. Creature addition (theme-weighted)
|
||
5. Non-creature spell categories & filler
|
||
6. Post-spell land color balancing & basic rebalance
|
||
7. CSV export (deck_files/<name>_<date>.csv)
|
||
"""
|
||
try:
|
||
self.run_initial_setup()
|
||
self.run_deck_build_step1()
|
||
self.run_deck_build_step2()
|
||
# Land steps
|
||
for step in range(1, 9):
|
||
m = getattr(self, f"run_land_step{step}", None)
|
||
if callable(m):
|
||
m()
|
||
# Creatures
|
||
if hasattr(self, 'add_creatures'):
|
||
self.add_creatures()
|
||
# Non-creature spells
|
||
if hasattr(self, 'add_non_creature_spells'):
|
||
self.add_non_creature_spells()
|
||
# Post-spell land adjustments
|
||
if hasattr(self, 'post_spell_land_adjust'):
|
||
self.post_spell_land_adjust()
|
||
# Export
|
||
if hasattr(self, 'export_decklist_csv'):
|
||
self.export_decklist_csv()
|
||
except KeyboardInterrupt:
|
||
self.output_func("\nDeck build cancelled by user.")
|
||
except Exception as e:
|
||
self.output_func(f"Deck build failed: {e}")
|
||
|
||
# ---------------------------
|
||
# RNG Initialization
|
||
# ---------------------------
|
||
def _get_rng(self): # lazy init to allow seed set post-construction
|
||
if self._rng is None:
|
||
import random as _r
|
||
self._rng = _r.Random(self.seed) if self.seed is not None else _r
|
||
return self._rng
|
||
|
||
# ---------------------------
|
||
# Data Loading
|
||
# ---------------------------
|
||
def load_commander_data(self) -> pd.DataFrame:
|
||
if self._commander_df is not None:
|
||
return self._commander_df
|
||
df = pd.read_csv(
|
||
bc.COMMANDER_CSV_PATH,
|
||
converters=getattr(bc, "COMMANDER_CONVERTERS", None)
|
||
)
|
||
if "themeTags" not in df.columns:
|
||
df["themeTags"] = [[] for _ in range(len(df))]
|
||
if "creatureTypes" not in df.columns:
|
||
df["creatureTypes"] = [[] for _ in range(len(df))]
|
||
self._commander_df = df
|
||
return df
|
||
|
||
# ---------------------------
|
||
# Fuzzy Search Helpers
|
||
# ---------------------------
|
||
def _auto_accept(self, query: str, candidate: str) -> bool:
|
||
full = _full_ratio(query, candidate)
|
||
if full >= EXACT_NAME_THRESHOLD:
|
||
return True
|
||
q_first = query.strip().split()[0].lower() if query.strip() else ""
|
||
c_first = candidate.split()[0].lower()
|
||
if q_first and _full_ratio(q_first, c_first) >= FIRST_WORD_THRESHOLD:
|
||
return True
|
||
return False
|
||
|
||
def _gather_candidates(self, query: str, names: List[str]) -> List[tuple]:
|
||
scored = _top_matches(query, names, MAX_PRESENTED_CHOICES)
|
||
uniq: Dict[str, int] = {}
|
||
for n, s in scored:
|
||
uniq[n] = max(uniq.get(n, 0), s)
|
||
return sorted(uniq.items(), key=lambda x: x[1], reverse=True)
|
||
|
||
# ---------------------------
|
||
# Commander Dict Initialization
|
||
# ---------------------------
|
||
def _initialize_commander_dict(self, row: pd.Series):
|
||
def get(field: str, default=""):
|
||
return row.get(field, default) if isinstance(row, pd.Series) else default
|
||
|
||
mana_cost = get("manaCost", "")
|
||
mana_value = get("manaValue", get("cmc", None))
|
||
try:
|
||
if mana_value is None and isinstance(mana_cost, str):
|
||
mana_value = mana_cost.count("}") if "}" in mana_cost else None
|
||
except Exception:
|
||
pass
|
||
|
||
color_identity_raw = get("colorIdentity", get("colors", []))
|
||
if isinstance(color_identity_raw, str):
|
||
stripped = color_identity_raw.strip("[] ")
|
||
if "," in stripped:
|
||
color_identity = [c.strip(" '\"") for c in stripped.split(",")]
|
||
else:
|
||
color_identity = list(stripped)
|
||
else:
|
||
color_identity = color_identity_raw if isinstance(color_identity_raw, list) else []
|
||
|
||
colors_field = get("colors", color_identity)
|
||
if isinstance(colors_field, str):
|
||
colors = list(colors_field)
|
||
else:
|
||
colors = colors_field if isinstance(colors_field, list) else []
|
||
|
||
type_line = get("type", get("type_line", ""))
|
||
creature_types = get("creatureTypes", [])
|
||
if isinstance(creature_types, str):
|
||
creature_types = [s.strip() for s in creature_types.split(",") if s.strip()]
|
||
|
||
text_field = get("text", get("oracleText", ""))
|
||
if isinstance(text_field, str):
|
||
text_field = text_field.replace("\\n", "\n")
|
||
|
||
power = get("power", "")
|
||
toughness = get("toughness", "")
|
||
themes = get("themeTags", [])
|
||
if isinstance(themes, str):
|
||
themes = [t.strip() for t in themes.split(",") if t.strip()]
|
||
|
||
cmc = get("cmc", mana_value if mana_value is not None else 0.0)
|
||
try:
|
||
cmc = float(cmc) if cmc not in ("", None) else 0.0
|
||
except Exception:
|
||
cmc = 0.0
|
||
|
||
self.commander_dict = {
|
||
"Commander Name": self.commander_name,
|
||
"Mana Cost": mana_cost,
|
||
"Mana Value": mana_value,
|
||
"Color Identity": color_identity,
|
||
"Colors": colors,
|
||
"Type": type_line,
|
||
"Creature Types": creature_types,
|
||
"Text": text_field,
|
||
"Power": power,
|
||
"Toughness": toughness,
|
||
"Themes": themes,
|
||
"CMC": cmc,
|
||
}
|
||
# Ensure commander added to card library
|
||
try:
|
||
self.add_card(
|
||
card_name=self.commander_name,
|
||
card_type=type_line,
|
||
mana_cost=mana_cost,
|
||
mana_value=cmc,
|
||
creature_types=creature_types if isinstance(creature_types, list) else [],
|
||
tags=themes if isinstance(themes, list) else [],
|
||
is_commander=True
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# ---------------------------
|
||
# Pretty Display
|
||
# ---------------------------
|
||
def _format_commander_pretty(self, row: pd.Series) -> str:
|
||
|
||
def norm(val):
|
||
if isinstance(val, list) and len(val) == 1:
|
||
val = val[0]
|
||
if val is None or (isinstance(val, float) and math.isnan(val)):
|
||
return "-"
|
||
return val
|
||
|
||
def join_list(val, sep=", "):
|
||
val = norm(val)
|
||
if isinstance(val, list):
|
||
return sep.join(str(x) for x in val) if val else "-"
|
||
return str(val)
|
||
|
||
name = norm(row.get("name", ""))
|
||
face_name = norm(row.get("faceName", name))
|
||
edhrec = norm(row.get("edhrecRank", "-"))
|
||
color_identity = join_list(row.get("colorIdentity", row.get("colors", [])), "")
|
||
colors = join_list(row.get("colors", []), "")
|
||
mana_cost = norm(row.get("manaCost", ""))
|
||
mana_value = norm(row.get("manaValue", row.get("cmc", "-")))
|
||
type_line = norm(row.get("type", row.get("type_line", "")))
|
||
creature_types = join_list(row.get("creatureTypes", []))
|
||
text_field = norm(row.get("text", row.get("oracleText", "")))
|
||
text_field = str(text_field).replace("\\n", "\n")
|
||
power = norm(row.get("power", "-"))
|
||
toughness = norm(row.get("toughness", "-"))
|
||
keywords = join_list(row.get("keywords", []))
|
||
raw_tags = row.get("themeTags", [])
|
||
if isinstance(raw_tags, str):
|
||
tags_list = [t.strip() for t in raw_tags.split(",") if t.strip()]
|
||
elif isinstance(raw_tags, list):
|
||
if len(raw_tags) == 1 and isinstance(raw_tags[0], list):
|
||
tags_list = raw_tags[0]
|
||
else:
|
||
tags_list = raw_tags
|
||
else:
|
||
tags_list = []
|
||
layout = norm(row.get("layout", "-"))
|
||
side = norm(row.get("side", "-"))
|
||
|
||
lines = [
|
||
"Selected Commander:",
|
||
f"Name: {name}",
|
||
f"Face Name: {face_name}",
|
||
f"EDHREC Rank: {edhrec}",
|
||
f"Color Identity: {color_identity}",
|
||
f"Colors: {colors}",
|
||
f"Mana Cost: {mana_cost}",
|
||
f"Mana Value: {mana_value}",
|
||
f"Type: {type_line}",
|
||
f"Creature Types: {creature_types}",
|
||
f"Power/Toughness: {power}/{toughness}",
|
||
f"Keywords: {keywords}",
|
||
f"Layout: {layout}",
|
||
f"Side: {side}",
|
||
]
|
||
if tags_list:
|
||
lines.append("Theme Tags:")
|
||
for t in tags_list:
|
||
lines.append(f" - {t}")
|
||
else:
|
||
lines.append("Theme Tags: -")
|
||
lines.extend([
|
||
"Text:",
|
||
text_field,
|
||
""
|
||
])
|
||
return "\n".join(lines)
|
||
|
||
def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool:
|
||
row = df[df["name"] == name].iloc[0]
|
||
pretty = self._format_commander_pretty(row)
|
||
self.output_func("\n" + pretty)
|
||
while True:
|
||
resp = self.input_func("Is this the commander you want? (y/n): ").strip().lower()
|
||
if resp in ("y", "yes"):
|
||
self._apply_commander_selection(row)
|
||
return True
|
||
if resp in ("n", "no"):
|
||
return False
|
||
self.output_func("Please enter y or n.")
|
||
|
||
# ---------------------------
|
||
# Commander Selection
|
||
# ---------------------------
|
||
def choose_commander(self) -> str:
|
||
df = self.load_commander_data()
|
||
names = df["name"].tolist()
|
||
while True:
|
||
query = self.input_func("Enter commander name: ").strip()
|
||
if not query:
|
||
self.output_func("No input provided. Try again.")
|
||
continue
|
||
|
||
direct_hits = [n for n in names if self._auto_accept(query, n)]
|
||
if len(direct_hits) == 1:
|
||
candidate = direct_hits[0]
|
||
self.output_func(f"(Auto match candidate) {candidate}")
|
||
if self._present_commander_and_confirm(df, candidate):
|
||
self.output_func(f"Confirmed: {candidate}")
|
||
return candidate
|
||
else:
|
||
self.output_func("Not confirmed. Starting over.\n")
|
||
continue
|
||
|
||
candidates = self._gather_candidates(query, names)
|
||
if not candidates:
|
||
self.output_func("No close matches found. Try again.")
|
||
continue
|
||
|
||
self.output_func("\nTop matches:")
|
||
for idx, (n, score) in enumerate(candidates, start=1):
|
||
self.output_func(f" {idx}. {n} (score {score})")
|
||
self.output_func("Enter number to inspect, 'r' to retry, or type a new name:")
|
||
|
||
choice = self.input_func("Selection: ").strip()
|
||
if choice.lower() == 'r':
|
||
continue
|
||
if choice.isdigit():
|
||
i = int(choice)
|
||
if 1 <= i <= len(candidates):
|
||
nm = candidates[i - 1][0]
|
||
if self._present_commander_and_confirm(df, nm):
|
||
self.output_func(f"Confirmed: {nm}")
|
||
return nm
|
||
else:
|
||
self.output_func("Not confirmed. Search again.\n")
|
||
continue
|
||
else:
|
||
self.output_func("Invalid index.")
|
||
continue
|
||
# Treat as new query
|
||
query = choice
|
||
|
||
def _apply_commander_selection(self, row: pd.Series):
|
||
self.commander_name = row["name"]
|
||
self.commander_row = row
|
||
self.commander_tags = list(row.get("themeTags", []) or [])
|
||
self._initialize_commander_dict(row)
|
||
|
||
# ---------------------------
|
||
# Color Identity & Card Pool Loading (New Step)
|
||
# ---------------------------
|
||
def _canonical_color_key(self, colors: List[str]) -> str:
|
||
"""Return canonical key like 'B, G, W' or 'COLORLESS'. Uses alphabetical ordering.
|
||
|
||
The legacy constants expect a specific ordering (alphabetical seems consistent in provided maps).
|
||
"""
|
||
if not colors:
|
||
return 'COLORLESS'
|
||
# Deduplicate & sort
|
||
uniq = sorted({c.strip().upper() for c in colors if c.strip()})
|
||
return ', '.join(uniq)
|
||
|
||
def determine_color_identity(self) -> Tuple[str, List[str]]:
|
||
"""Determine color identity key/full name and derive csv file list.
|
||
|
||
Returns (color_identity_full, files_to_load).
|
||
"""
|
||
if self.commander_row is None:
|
||
raise RuntimeError("Commander must be selected before determining color identity.")
|
||
|
||
raw_ci = self.commander_row.get('colorIdentity')
|
||
if isinstance(raw_ci, list):
|
||
colors_list = raw_ci
|
||
elif isinstance(raw_ci, str) and raw_ci.strip():
|
||
# Could be formatted like "['B','G']" or 'BG'; attempt simple parsing
|
||
if ',' in raw_ci:
|
||
colors_list = [c.strip().strip("'[] ") for c in raw_ci.split(',') if c.strip().strip("'[] ")]
|
||
else:
|
||
colors_list = [c for c in raw_ci if c.isalpha()]
|
||
else:
|
||
# Fallback to 'colors' field or treat as colorless
|
||
alt = self.commander_row.get('colors')
|
||
if isinstance(alt, list):
|
||
colors_list = alt
|
||
elif isinstance(alt, str) and alt.strip():
|
||
colors_list = [c for c in alt if c.isalpha()]
|
||
else:
|
||
colors_list = []
|
||
|
||
self.color_identity = [c.upper() for c in colors_list]
|
||
self.color_identity_key = self._canonical_color_key(self.color_identity)
|
||
|
||
# Match against maps
|
||
full = None
|
||
load_files: List[str] = []
|
||
key = self.color_identity_key
|
||
if key in bc.MONO_COLOR_MAP:
|
||
full, load_files = bc.MONO_COLOR_MAP[key]
|
||
elif key in bc.DUAL_COLOR_MAP:
|
||
info = bc.DUAL_COLOR_MAP[key]
|
||
full, load_files = info[0], info[2]
|
||
elif key in bc.TRI_COLOR_MAP:
|
||
info = bc.TRI_COLOR_MAP[key]
|
||
full, load_files = info[0], info[2]
|
||
elif key in bc.OTHER_COLOR_MAP:
|
||
info = bc.OTHER_COLOR_MAP[key]
|
||
full, load_files = info[0], info[2]
|
||
else:
|
||
# Unknown / treat as colorless fallback
|
||
full, load_files = 'Unknown', ['colorless']
|
||
|
||
self.color_identity_full = full
|
||
self.files_to_load = load_files
|
||
return full, load_files
|
||
|
||
def setup_dataframes(self) -> pd.DataFrame:
|
||
"""Load all csv files for current color identity into one combined DataFrame.
|
||
|
||
Each file stem in files_to_load corresponds to csv_files/{stem}_cards.csv.
|
||
The result is cached and returned. Minimal validation only (non-empty, required columns exist if known).
|
||
"""
|
||
if self._combined_cards_df is not None:
|
||
return self._combined_cards_df
|
||
if not self.files_to_load:
|
||
# Attempt to determine if not yet done
|
||
self.determine_color_identity()
|
||
dfs = []
|
||
required = getattr(bc, 'CSV_REQUIRED_COLUMNS', [])
|
||
for stem in self.files_to_load:
|
||
path = f'csv_files/{stem}_cards.csv'
|
||
try:
|
||
df = pd.read_csv(path)
|
||
if required:
|
||
missing = [c for c in required if c not in df.columns]
|
||
if missing:
|
||
# Skip or still keep with warning; choose to warn
|
||
self.output_func(f"Warning: {path} missing columns: {missing}")
|
||
dfs.append(df)
|
||
except FileNotFoundError:
|
||
self.output_func(f"Warning: CSV file not found: {path}")
|
||
continue
|
||
if not dfs:
|
||
raise RuntimeError("No CSV files loaded for color identity.")
|
||
combined = pd.concat(dfs, axis=0, ignore_index=True)
|
||
# Drop duplicate rows by 'name' if column exists
|
||
if 'name' in combined.columns:
|
||
combined = combined.drop_duplicates(subset='name', keep='first')
|
||
self._combined_cards_df = combined
|
||
# Preserve original snapshot for enrichment across subsequent removals
|
||
if self._full_cards_df is None:
|
||
self._full_cards_df = combined.copy()
|
||
return combined
|
||
|
||
# ---------------------------
|
||
# Card Library Management
|
||
# ---------------------------
|
||
def add_card(self,
|
||
card_name: str,
|
||
card_type: str = '',
|
||
mana_cost: str = '',
|
||
mana_value: Optional[float] = None,
|
||
creature_types: Optional[List[str]] = None,
|
||
tags: Optional[List[str]] = None,
|
||
is_commander: bool = False) -> None:
|
||
"""Add (or increment) a card in the deck library.
|
||
|
||
Stores minimal metadata; duplicates increment Count. Basic lands allowed unlimited.
|
||
"""
|
||
if creature_types is None:
|
||
creature_types = []
|
||
if tags is None:
|
||
tags = []
|
||
# Compute mana value if missing from cost (simple heuristic: count symbols between braces)
|
||
if mana_value is None and mana_cost:
|
||
try:
|
||
if '{' in mana_cost and '}' in mana_cost:
|
||
# naive parse: digits add numeric value; individual colored symbols count as 1
|
||
symbols = re.findall(r'\{([^}]+)\}', mana_cost)
|
||
total = 0
|
||
for sym in symbols:
|
||
if sym.isdigit():
|
||
total += int(sym)
|
||
else:
|
||
total += 1
|
||
mana_value = total
|
||
except Exception:
|
||
mana_value = None
|
||
entry = self.card_library.get(card_name)
|
||
if entry:
|
||
# Enforce Commander singleton rules: only basic lands may have multiple copies
|
||
try:
|
||
from deck_builder import builder_constants as bc
|
||
from settings import MULTIPLE_COPY_CARDS
|
||
except Exception:
|
||
MULTIPLE_COPY_CARDS = [] # type: ignore
|
||
is_land = 'land' in str(card_type or entry.get('Card Type','')).lower()
|
||
is_basic = False
|
||
try:
|
||
basic_list = getattr(bc, 'BASIC_LANDS', [])
|
||
is_basic = any(card_name == bl or card_name.startswith(bl + ' ') for bl in basic_list)
|
||
except Exception:
|
||
pass
|
||
if is_land and not is_basic:
|
||
# Non-basic land: do not increment
|
||
return
|
||
if card_name in MULTIPLE_COPY_CARDS:
|
||
# Explicit multi-copy list still restricted to 1 in Commander context
|
||
return
|
||
# Basic lands (or other allowed future exceptions) increment
|
||
entry['Count'] += 1
|
||
else:
|
||
# If no tags passed attempt enrichment from full snapshot / combined pool
|
||
if not tags:
|
||
df_src = self._full_cards_df if self._full_cards_df is not None else self._combined_cards_df
|
||
try:
|
||
if df_src is not None and not df_src.empty and 'name' in df_src.columns:
|
||
row_match = df_src[df_src['name'] == card_name]
|
||
if not row_match.empty:
|
||
raw_tags = row_match.iloc[0].get('themeTags', [])
|
||
if isinstance(raw_tags, list):
|
||
tags = [str(t).strip() for t in raw_tags if str(t).strip()]
|
||
elif isinstance(raw_tags, str) and raw_tags.strip():
|
||
# tolerate comma separated
|
||
parts = [p.strip().strip("'\"") for p in raw_tags.split(',')]
|
||
tags = [p for p in parts if p]
|
||
except Exception:
|
||
pass
|
||
# Normalize & dedupe tags
|
||
norm_tags: list[str] = []
|
||
seen_tag = set()
|
||
for t in tags:
|
||
if not isinstance(t, str):
|
||
t = str(t)
|
||
tt = t.strip()
|
||
if not tt or tt.lower() == 'nan':
|
||
continue
|
||
if tt not in seen_tag:
|
||
norm_tags.append(tt)
|
||
seen_tag.add(tt)
|
||
tags = norm_tags
|
||
self.card_library[card_name] = {
|
||
'Card Name': card_name,
|
||
'Card Type': card_type,
|
||
'Mana Cost': mana_cost,
|
||
'Mana Value': mana_value,
|
||
'Creature Types': creature_types,
|
||
'Tags': tags,
|
||
'Commander': is_commander,
|
||
'Count': 1,
|
||
'Role': None # placeholder for 'flex', 'suggested', etc.
|
||
}
|
||
# Update tag counts for new unique card
|
||
tag_set = set(tags)
|
||
self._card_name_tags_index[card_name] = tag_set
|
||
for tg in tag_set:
|
||
self.tag_counts[tg] = self.tag_counts.get(tg, 0) + 1
|
||
# Keep commander dict CMC up to date if adding commander
|
||
if is_commander and self.commander_dict:
|
||
if mana_value is not None:
|
||
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:
|
||
return
|
||
df = self._combined_cards_df
|
||
if 'name' in df.columns:
|
||
self._combined_cards_df = df[df['name'] != card_name]
|
||
elif 'Card Name' in df.columns:
|
||
self._combined_cards_df = df[df['Card Name'] != card_name]
|
||
|
||
# ---------------------------
|
||
# Tag Prioritization
|
||
# ---------------------------
|
||
def select_commander_tags(self) -> List[str]:
|
||
if not self.commander_name:
|
||
self.output_func("No commander chosen yet. Selecting commander first...")
|
||
self.choose_commander()
|
||
|
||
tags = list(dict.fromkeys(self.commander_tags))
|
||
if not tags:
|
||
self.output_func("Commander has no theme tags available.")
|
||
self.selected_tags = []
|
||
self.primary_tag = self.secondary_tag = self.tertiary_tag = None
|
||
self._update_commander_dict_with_selected_tags()
|
||
return self.selected_tags
|
||
|
||
self.output_func("\nAvailable Theme Tags:")
|
||
for i, t in enumerate(tags, 1):
|
||
self.output_func(f" {i}. {t}")
|
||
|
||
self.selected_tags = []
|
||
# Primary (required)
|
||
self.primary_tag = self._prompt_tag_choice(tags, "Select PRIMARY tag (required):", allow_stop=False)
|
||
self.selected_tags.append(self.primary_tag)
|
||
|
||
remaining = [t for t in tags if t not in self.selected_tags]
|
||
|
||
# Secondary (optional)
|
||
if remaining:
|
||
self.secondary_tag = self._prompt_tag_choice(
|
||
remaining,
|
||
"Select SECONDARY tag (or 0 to stop here):",
|
||
allow_stop=True
|
||
)
|
||
if self.secondary_tag:
|
||
self.selected_tags.append(self.secondary_tag)
|
||
remaining = [t for t in remaining if t != self.secondary_tag]
|
||
|
||
# Tertiary (optional)
|
||
if remaining and self.secondary_tag:
|
||
self.tertiary_tag = self._prompt_tag_choice(
|
||
remaining,
|
||
"Select TERTIARY tag (or 0 to stop here):",
|
||
allow_stop=True
|
||
)
|
||
if self.tertiary_tag:
|
||
self.selected_tags.append(self.tertiary_tag)
|
||
|
||
self.output_func("\nChosen Tags (in priority order):")
|
||
if not self.selected_tags:
|
||
self.output_func(" (None)")
|
||
else:
|
||
for idx, tag in enumerate(self.selected_tags, 1):
|
||
label = ["Primary", "Secondary", "Tertiary"][idx - 1] if idx <= 3 else f"Tag {idx}"
|
||
self.output_func(f" {idx}. {tag} ({label})")
|
||
|
||
self._update_commander_dict_with_selected_tags()
|
||
return self.selected_tags
|
||
|
||
def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]:
|
||
while True:
|
||
self.output_func("\nCurrent options:")
|
||
for i, t in enumerate(available, 1):
|
||
self.output_func(f" {i}. {t}")
|
||
if allow_stop:
|
||
self.output_func(" 0. Stop (no further tags)")
|
||
raw = self.input_func(f"{prompt_text} ").strip()
|
||
if allow_stop and raw == "0":
|
||
return None
|
||
if raw.isdigit():
|
||
idx = int(raw)
|
||
if 1 <= idx <= len(available):
|
||
return available[idx - 1]
|
||
matches = [t for t in available if t.lower() == raw.lower()]
|
||
if matches:
|
||
return matches[0]
|
||
self.output_func("Invalid selection. Try again.")
|
||
|
||
def _update_commander_dict_with_selected_tags(self):
|
||
if not self.commander_dict and self.commander_row is not None:
|
||
self._initialize_commander_dict(self.commander_row)
|
||
if not self.commander_dict:
|
||
return
|
||
self.commander_dict["Primary Tag"] = self.primary_tag
|
||
self.commander_dict["Secondary Tag"] = self.secondary_tag
|
||
self.commander_dict["Tertiary Tag"] = self.tertiary_tag
|
||
self.commander_dict["Selected Tags"] = self.selected_tags.copy()
|
||
|
||
# ---------------------------
|
||
# Power Bracket Selection (Deck Building Step 1)
|
||
# ---------------------------
|
||
def select_power_bracket(self) -> BracketDefinition:
|
||
if self.bracket_definition:
|
||
return self.bracket_definition
|
||
|
||
self.output_func("\nChoose Deck Power Bracket:")
|
||
for bd in BRACKET_DEFINITIONS:
|
||
self.output_func(f" {bd.level}. {bd.name} - {bd.short_desc}")
|
||
|
||
while True:
|
||
raw = self.input_func("Enter bracket number (1-5) or 'info' for details: ").strip().lower()
|
||
if raw == "info":
|
||
self._print_bracket_details()
|
||
continue
|
||
if raw.isdigit():
|
||
num = int(raw)
|
||
match = next((bd for bd in BRACKET_DEFINITIONS if bd.level == num), None)
|
||
if match:
|
||
self.bracket_definition = match
|
||
self.bracket_level = match.level
|
||
self.bracket_name = match.name
|
||
self.bracket_limits = match.limits.copy()
|
||
self.output_func(f"\nSelected Bracket {match.level}: {match.name}")
|
||
self._print_selected_bracket_summary()
|
||
return match
|
||
self.output_func("Invalid input. Type 1-5 or 'info'.")
|
||
|
||
def _print_bracket_details(self):
|
||
self.output_func("\nBracket Details:")
|
||
for bd in BRACKET_DEFINITIONS:
|
||
self.output_func(f"\n[{bd.level}] {bd.name}")
|
||
self.output_func(bd.long_desc)
|
||
self.output_func(self._format_limits(bd.limits))
|
||
|
||
def _print_selected_bracket_summary(self):
|
||
if not self.bracket_definition:
|
||
return
|
||
self.output_func("\nBracket Constraints:")
|
||
self.output_func(self._format_limits(self.bracket_limits))
|
||
|
||
@staticmethod
|
||
def _format_limits(limits: Dict[str, Optional[int]]) -> str:
|
||
labels = {
|
||
"game_changers": "Game Changers",
|
||
"mass_land_denial": "Mass Land Denial",
|
||
"extra_turns": "Extra Turn Cards",
|
||
"tutors_nonland": "Nonland Tutors",
|
||
"two_card_combos": "Two-Card Combos"
|
||
}
|
||
lines = []
|
||
for key, label in labels.items():
|
||
val = limits.get(key, None)
|
||
if val is None:
|
||
lines.append(f" {label}: Unlimited")
|
||
else:
|
||
lines.append(f" {label}: {val}")
|
||
return "\n".join(lines)
|
||
|
||
def run_deck_build_step1(self):
|
||
self.select_power_bracket()
|
||
|
||
# ---------------------------
|
||
# Reporting Helper
|
||
# ---------------------------
|
||
def print_commander_dict_table(self):
|
||
if self.commander_row is None:
|
||
self.output_func("No commander selected.")
|
||
return
|
||
block = self._format_commander_pretty(self.commander_row)
|
||
self.output_func("\n" + block)
|
||
# New: show which CSV files (stems) were loaded for this color identity
|
||
if self.files_to_load:
|
||
file_list = ", ".join(f"{stem}_cards.csv" for stem in self.files_to_load)
|
||
self.output_func(f"Card Pool Files: {file_list}")
|
||
if self.selected_tags:
|
||
self.output_func("Chosen Tags:")
|
||
if self.primary_tag:
|
||
self.output_func(f" Primary : {self.primary_tag}")
|
||
if self.secondary_tag:
|
||
self.output_func(f" Secondary: {self.secondary_tag}")
|
||
if self.tertiary_tag:
|
||
self.output_func(f" Tertiary : {self.tertiary_tag}")
|
||
self.output_func("")
|
||
if self.bracket_definition:
|
||
self.output_func(f"Power Bracket: {self.bracket_level} - {self.bracket_name}")
|
||
self.output_func(self._format_limits(self.bracket_limits))
|
||
self.output_func("")
|
||
|
||
# ---------------------------
|
||
# Orchestration
|
||
# ---------------------------
|
||
def run_initial_setup(self):
|
||
self.choose_commander()
|
||
self.select_commander_tags()
|
||
# New: color identity & card pool loading
|
||
try:
|
||
self.determine_color_identity()
|
||
self.setup_dataframes()
|
||
except Exception as e:
|
||
self.output_func(f"Failed to load color-identity card pool: {e}")
|
||
self.print_commander_dict_table()
|
||
|
||
def run_full_initial_with_bracket(self):
|
||
self.run_initial_setup()
|
||
self.run_deck_build_step1()
|
||
# (Further steps can be chained here)
|
||
self.print_commander_dict_table()
|
||
|
||
# ===========================
|
||
# Deck Building Step 2: Ideal Composition Counts
|
||
# ===========================
|
||
ideal_counts: Dict[str, int] = field(default_factory=dict)
|
||
|
||
def run_deck_build_step2(self) -> Dict[str, int]:
|
||
"""Determine ideal counts for general card categories (bracket‑agnostic baseline).
|
||
|
||
Prompts the user (Enter to keep default). Stores results in ideal_counts and returns it.
|
||
Categories:
|
||
ramp, lands, basic_lands, creatures, removal, wipes, card_advantage, protection
|
||
"""
|
||
# Initialize defaults from constants if not already present
|
||
defaults = {
|
||
'ramp': bc.DEFAULT_RAMP_COUNT,
|
||
'lands': bc.DEFAULT_LAND_COUNT,
|
||
'basic_lands': bc.DEFAULT_BASIC_LAND_COUNT,
|
||
'creatures': bc.DEFAULT_CREATURE_COUNT,
|
||
'removal': bc.DEFAULT_REMOVAL_COUNT,
|
||
'wipes': bc.DEFAULT_WIPES_COUNT,
|
||
'card_advantage': bc.DEFAULT_CARD_ADVANTAGE_COUNT,
|
||
'protection': bc.DEFAULT_PROTECTION_COUNT,
|
||
}
|
||
|
||
# Seed existing values if already set (allow re-run keeping previous choices)
|
||
for k, v in defaults.items():
|
||
if k not in self.ideal_counts:
|
||
self.ideal_counts[k] = v
|
||
|
||
self.output_func("\nSet Ideal Deck Composition Counts (press Enter to accept default/current):")
|
||
for key, prompt in bc.DECK_COMPOSITION_PROMPTS.items():
|
||
if key not in defaults: # skip price prompts & others for this step
|
||
continue
|
||
current_default = self.ideal_counts[key]
|
||
value = self._prompt_int_with_default(f"{prompt} ", current_default, minimum=0, maximum=200)
|
||
self.ideal_counts[key] = value
|
||
|
||
# Basic validation adjustments
|
||
# Ensure basic_lands <= lands
|
||
if self.ideal_counts['basic_lands'] > self.ideal_counts['lands']:
|
||
self.output_func("Adjusting basic lands to not exceed total lands.")
|
||
self.ideal_counts['basic_lands'] = self.ideal_counts['lands']
|
||
|
||
self._print_ideal_counts_summary()
|
||
return self.ideal_counts
|
||
|
||
# Helper to prompt integer values with default
|
||
def _prompt_int_with_default(self, prompt: str, default: int, minimum: int = 0, maximum: int = 999) -> int:
|
||
while True:
|
||
raw = self.input_func(f"{prompt}[{default}] ").strip()
|
||
if raw == "":
|
||
return default
|
||
if raw.isdigit():
|
||
val = int(raw)
|
||
if minimum <= val <= maximum:
|
||
return val
|
||
self.output_func(f"Enter a number between {minimum} and {maximum}, or press Enter for {default}.")
|
||
|
||
def _print_ideal_counts_summary(self):
|
||
self.output_func("\nIdeal Composition Targets:")
|
||
order = [
|
||
('ramp', 'Ramp Pieces'),
|
||
('lands', 'Total Lands'),
|
||
('basic_lands', 'Minimum Basic Lands'),
|
||
('creatures', 'Creatures'),
|
||
('removal', 'Spot Removal'),
|
||
('wipes', 'Board Wipes'),
|
||
('card_advantage', 'Card Advantage'),
|
||
('protection', 'Protection')
|
||
]
|
||
width = max(len(label) for _, label in order)
|
||
for key, label in order:
|
||
if key in self.ideal_counts:
|
||
self.output_func(f" {label.ljust(width)} : {self.ideal_counts[key]}")
|
||
|
||
# Public wrapper for external callers / tests
|
||
def print_ideal_counts(self):
|
||
if not self.ideal_counts:
|
||
self.output_func("Ideal counts not set. Run run_deck_build_step2() first.")
|
||
return
|
||
# Reuse formatting but with a simpler heading per user request
|
||
self.output_func("\nIdeal Counts:")
|
||
order = [
|
||
('ramp', 'Ramp'),
|
||
('lands', 'Total Lands'),
|
||
('basic_lands', 'Basic Lands (Min)'),
|
||
('creatures', 'Creatures'),
|
||
('removal', 'Spot Removal'),
|
||
('wipes', 'Board Wipes'),
|
||
('card_advantage', 'Card Advantage'),
|
||
('protection', 'Protection')
|
||
]
|
||
width = max(len(label) for _, label in order)
|
||
for key, label in order:
|
||
if key in self.ideal_counts:
|
||
self.output_func(f" {label.ljust(width)} : {self.ideal_counts[key]}")
|
||
|
||
# ---------------------------
|
||
# Land Building Step 1: Basic Lands
|
||
# ---------------------------
|
||
def add_basic_lands(self):
|
||
"""Add basic (or snow basic) lands based on color identity.
|
||
|
||
Logic:
|
||
- Determine target basics = ceil(1.3 * ideal_basic_min) (rounded) but capped by total land target
|
||
- Evenly distribute among colored identity letters (W,U,B,R,G)
|
||
- If commander/selected tags include 'Snow' (case-insensitive) use snow basics mapping
|
||
- Colorless commander: use Wastes for the entire basic allocation
|
||
"""
|
||
# Ensure color identity determined
|
||
if not self.files_to_load:
|
||
try:
|
||
self.determine_color_identity()
|
||
self.setup_dataframes()
|
||
except Exception as e:
|
||
self.output_func(f"Cannot add basics until color identity resolved: {e}")
|
||
return
|
||
|
||
# Ensure ideal counts (for min basics & total lands)
|
||
basic_min = None
|
||
land_total = None
|
||
if hasattr(self, 'ideal_counts') and self.ideal_counts:
|
||
basic_min = self.ideal_counts.get('basic_lands')
|
||
land_total = self.ideal_counts.get('lands')
|
||
if basic_min is None:
|
||
basic_min = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
|
||
if land_total is None:
|
||
land_total = getattr(bc, 'DEFAULT_LAND_COUNT', 35)
|
||
|
||
# Target basics = 1.3 * minimum (rounded) but not exceeding total lands
|
||
target_basics = int(round(1.3 * basic_min))
|
||
if target_basics > land_total:
|
||
target_basics = land_total
|
||
if target_basics <= 0:
|
||
self.output_func("Target basic land count is zero; skipping basics.")
|
||
return
|
||
|
||
colors = [c for c in self.color_identity if c in ['W','U','B','R','G']]
|
||
if not colors:
|
||
# Colorless
|
||
colors = [] # special case: use Wastes only
|
||
|
||
# Determine if snow preferred
|
||
tag_pool = (self.selected_tags or []) + (self.commander_tags if hasattr(self, 'commander_tags') else [])
|
||
use_snow = any('snow' in str(t).lower() for t in tag_pool)
|
||
snow_map = getattr(bc, 'SNOW_BASIC_LAND_MAPPING', {})
|
||
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
|
||
|
||
allocation: Dict[str, int] = {}
|
||
if not colors: # colorless
|
||
allocation_name = snow_map.get('C', 'Wastes') if use_snow else 'Wastes'
|
||
allocation[allocation_name] = target_basics
|
||
else:
|
||
n = len(colors)
|
||
base = target_basics // n
|
||
rem = target_basics % n
|
||
for idx, c in enumerate(sorted(colors)): # sorted for deterministic distribution
|
||
count = base + (1 if idx < rem else 0)
|
||
land_name = snow_map.get(c) if use_snow else basic_map.get(c)
|
||
if not land_name:
|
||
continue
|
||
allocation[land_name] = allocation.get(land_name, 0) + count
|
||
|
||
# Add to library
|
||
for land_name, count in allocation.items():
|
||
for _ in range(count):
|
||
self.add_card(land_name, card_type='Land')
|
||
|
||
# Summary output
|
||
self.output_func("\nBasic Lands Added:")
|
||
width = max(len(n) for n in allocation.keys()) if allocation else 0
|
||
for name, cnt in allocation.items():
|
||
self.output_func(f" {name.ljust(width)} : {cnt}")
|
||
self.output_func(f" Total Basics : {sum(allocation.values())} (Target {target_basics}, Min {basic_min})")
|
||
|
||
def run_land_step1(self):
|
||
"""Public wrapper to execute land building step 1 (basics)."""
|
||
self.add_basic_lands()
|
||
|
||
# ---------------------------
|
||
# Land Building Step 2: Staple Nonbasic Lands (NO Kindred yet)
|
||
# ---------------------------
|
||
def _current_land_count(self) -> int:
|
||
"""Return total number of land cards currently in the library (counts duplicates)."""
|
||
total = 0
|
||
for name, entry in self.card_library.items():
|
||
# If we recorded type when adding basics or staples, use that
|
||
ctype = entry.get('Card Type', '')
|
||
if ctype and 'land' in ctype.lower():
|
||
total += entry.get('Count', 1)
|
||
continue
|
||
# Else attempt enrichment from combined pool
|
||
if self._combined_cards_df is not None and 'name' in self._combined_cards_df.columns:
|
||
row = self._combined_cards_df[self._combined_cards_df['name'] == name]
|
||
if not row.empty:
|
||
type_field = str(row.iloc[0].get('type', '')).lower()
|
||
if 'land' in type_field:
|
||
total += entry.get('Count', 1)
|
||
return total
|
||
|
||
def add_staple_lands(self):
|
||
"""Add generic staple lands defined in STAPLE_LAND_CONDITIONS (excluding kindred lands).
|
||
|
||
Respects total land target (ideal_counts['lands']). Skips additions once target reached.
|
||
Conditions may use commander tags (all available, not just selected), color identity, and commander power.
|
||
"""
|
||
# Ensure color identity and card pool 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 staple lands until color identity resolved: {e}")
|
||
return
|
||
|
||
# Determine land target
|
||
land_target = None
|
||
if hasattr(self, 'ideal_counts') and self.ideal_counts:
|
||
land_target = self.ideal_counts.get('lands')
|
||
if land_target is None:
|
||
land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35)
|
||
|
||
# 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
|
||
commander_power = 0
|
||
try:
|
||
if self.commander_row is not None:
|
||
raw_power = self.commander_row.get('power')
|
||
if isinstance(raw_power, (int, float)):
|
||
commander_power = int(raw_power)
|
||
elif isinstance(raw_power, str) and raw_power.isdigit():
|
||
commander_power = int(raw_power)
|
||
except Exception:
|
||
commander_power = 0
|
||
|
||
added: List[str] = []
|
||
reasons: Dict[str, str] = {}
|
||
for land_name, cond in getattr(bc, 'STAPLE_LAND_CONDITIONS', {}).items():
|
||
# 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:
|
||
continue
|
||
try:
|
||
include = cond(list(commander_tags_all), colors, commander_power)
|
||
except Exception:
|
||
include = False
|
||
if include:
|
||
self.add_card(land_name, card_type='Land')
|
||
added.append(land_name)
|
||
# Basic reason heuristics for transparency
|
||
if land_name == 'Command Tower':
|
||
reasons[land_name] = f"multi-color ({len(colors)} colors)"
|
||
elif land_name == 'Exotic Orchard':
|
||
reasons[land_name] = f"multi-color ({len(colors)} colors)"
|
||
elif land_name == 'War Room':
|
||
reasons[land_name] = f"<=2 colors ({len(colors)})"
|
||
elif land_name == 'Reliquary Tower':
|
||
reasons[land_name] = 'always include'
|
||
elif land_name == 'Ash Barrens':
|
||
reasons[land_name] = 'no Landfall tag'
|
||
elif land_name == "Rogue's Passage":
|
||
reasons[land_name] = f"commander power {commander_power} >=5"
|
||
|
||
self.output_func("\nStaple Lands Added (Step 2):")
|
||
if not added:
|
||
self.output_func(" (None added)")
|
||
else:
|
||
width = max(len(n) for n in added)
|
||
for n in added:
|
||
reason = reasons.get(n, '')
|
||
self.output_func(f" {n.ljust(width)} : 1 {('(' + reason + ')') if reason else ''}")
|
||
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
|
||
|
||
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
|
||
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.
|
||
"""
|
||
try:
|
||
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:
|
||
rng_obj = getattr(self, 'rng', None)
|
||
try:
|
||
weighted: list[tuple[str, int]] = []
|
||
for n in names:
|
||
w = max(1, rank(n)) + 1
|
||
weighted.append((n, w))
|
||
shuffled: list[str] = []
|
||
while weighted:
|
||
total = sum(w for _, w in weighted)
|
||
r = (rng_obj.random() if rng_obj else self._get_rng().random()) * total
|
||
acc = 0.0
|
||
for idx, (n, w) in enumerate(weighted):
|
||
acc += w
|
||
if r <= acc:
|
||
shuffled.append(n)
|
||
del weighted[idx]
|
||
break
|
||
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 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:
|
||
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 _, w in weighted)
|
||
r = (rng_obj.random() if rng_obj else self._get_rng().random()) * total
|
||
acc = 0.0
|
||
for idx, (n, w) in enumerate(weighted):
|
||
acc += w
|
||
if r <= acc:
|
||
shuffled.append(n)
|
||
del weighted[idx]
|
||
break
|
||
tri_buckets[key] = shuffled
|
||
except Exception:
|
||
tri_buckets[key] = names
|
||
else:
|
||
tri_buckets[key] = names
|
||
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())
|
||
|
||
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
|
||
|
||
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):
|
||
|
||
# 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
|
||
|
||
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
|
||
|
||
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 = True,
|
||
max_swaps: int = 5,
|
||
rebalance_basics: bool = True):
|
||
# 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,str]] = [] # (removed, added, target_color)
|
||
|
||
# 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]:
|
||
|
||
return bu.select_color_balance_removal(self, exclude_colors, overages)
|
||
|
||
def addition_candidates(target_color: str) -> List[str]:
|
||
|
||
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 = None
|
||
for cand in adds:
|
||
if cand not in self.card_library:
|
||
to_add = cand
|
||
break
|
||
if not to_add:
|
||
continue
|
||
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, color))
|
||
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}
|
||
new_gap = pip_weights.get(color,0.0) - source_share.get(color,0.0)
|
||
if new_gap <= color_shortfall_threshold:
|
||
continue
|
||
|
||
if swaps_done:
|
||
self.output_func("\nColor Balance Swaps Performed:")
|
||
for old, new, col in swaps_done:
|
||
self.output_func(f" [{col}] Replaced {old} -> {new}")
|
||
final_counts = self._current_color_source_counts()
|
||
final_total = sum(final_counts.values()) or 1
|
||
final_source_share = {c: final_counts[c]/final_total for c in final_counts}
|
||
self.output_func(" Updated Source Shares:")
|
||
for c in ['W','U','B','R','G']:
|
||
self.output_func(f" {c}: {final_source_share.get(c,0.0)*100:5.1f}% (pip {pip_weights.get(c,0.0)*100:5.1f}%)")
|
||
if rebalance_basics:
|
||
try:
|
||
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
|
||
basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()}
|
||
if basics_present:
|
||
total_basics = sum(e.get('Count',1) for e in basics_present.values())
|
||
if total_basics > 0:
|
||
desired_per_color: dict[str,int] = {}
|
||
for c, basic_name in basic_map.items():
|
||
if c not in ['W','U','B','R','G']:
|
||
continue
|
||
desired = pip_weights.get(c,0.0) * total_basics
|
||
desired_per_color[c] = int(round(desired))
|
||
drift = total_basics - sum(desired_per_color.values())
|
||
if drift != 0:
|
||
ordered = sorted(desired_per_color.items(), key=lambda kv: pip_weights.get(kv[0],0.0), reverse=(drift>0))
|
||
i = 0
|
||
while drift != 0 and ordered:
|
||
c,_ = ordered[i % len(ordered)]
|
||
desired_per_color[c] += 1 if drift>0 else -1
|
||
drift += -1 if drift>0 else 1
|
||
i += 1
|
||
changes: list[tuple[str,int,int]] = []
|
||
for c, basic_name in basic_map.items():
|
||
if c not in ['W','U','B','R','G']:
|
||
continue
|
||
target = max(0, desired_per_color.get(c,0))
|
||
entry = self.card_library.get(basic_name)
|
||
old = entry.get('Count',0) if entry else 0
|
||
if old == 0 and target>0:
|
||
for _ in range(target):
|
||
self.add_card(basic_name, card_type='Land')
|
||
changes.append((basic_name, 0, target))
|
||
elif entry and old != target:
|
||
if target > old:
|
||
for _ in range(target-old):
|
||
self.add_card(basic_name, card_type='Land')
|
||
else:
|
||
for _ in range(old-target):
|
||
self._decrement_card(basic_name)
|
||
changes.append((basic_name, old, target))
|
||
if changes:
|
||
self.output_func("\nBasic Land Rebalance (toward pip distribution):")
|
||
for nm, old, new in changes:
|
||
self.output_func(f" {nm}: {old} -> {new}")
|
||
except Exception as e:
|
||
self.output_func(f" Basic rebalance skipped (error: {e})")
|
||
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."""
|
||
|
||
return bu.basic_land_names()
|
||
|
||
def _count_basic_lands(self) -> int:
|
||
"""Count total copies of basic lands currently in the library."""
|
||
|
||
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."""
|
||
|
||
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."""
|
||
|
||
bu.enforce_land_cap(self, step_label)
|
||
|
||
# ===========================
|
||
# Non-Land Addition: Creatures
|
||
# ===========================
|
||
def add_creatures(self):
|
||
"""Add creature cards distributed across selected themes (1-3).
|
||
|
||
Unified logic replacing previous add_creatures_primary / add_creatures_by_themes.
|
||
Weight scheme:
|
||
1 theme: 100%
|
||
2 themes: 60/40
|
||
3 themes: 50/30/20
|
||
Kindred multipliers applied only when >1 theme.
|
||
Synergy prioritizes cards matching multiple selected themes.
|
||
"""
|
||
df = getattr(self, '_combined_cards_df', None)
|
||
if df is None or df.empty:
|
||
self.output_func("Card pool not loaded; cannot add creatures.")
|
||
return
|
||
if 'type' not in df.columns:
|
||
self.output_func("Card pool missing 'type' column; cannot add creatures.")
|
||
return
|
||
themes_ordered: list[tuple[str, str]] = []
|
||
if self.primary_tag:
|
||
themes_ordered.append(('primary', self.primary_tag))
|
||
if self.secondary_tag:
|
||
themes_ordered.append(('secondary', self.secondary_tag))
|
||
if self.tertiary_tag:
|
||
themes_ordered.append(('tertiary', self.tertiary_tag))
|
||
if not themes_ordered:
|
||
self.output_func("No themes selected; skipping creature addition.")
|
||
return
|
||
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
|
||
n_themes = len(themes_ordered)
|
||
if n_themes == 1:
|
||
base_map = {'primary': 1.0}
|
||
elif n_themes == 2:
|
||
base_map = {'primary': 0.6, 'secondary': 0.4}
|
||
else:
|
||
base_map = {'primary': 0.5, 'secondary': 0.3, 'tertiary': 0.2}
|
||
weights: dict[str, float] = {}
|
||
boosted_roles: set[str] = set()
|
||
if n_themes > 1:
|
||
for role, tag in themes_ordered:
|
||
w = base_map.get(role, 0.0)
|
||
lt = tag.lower()
|
||
if 'kindred' in lt or 'tribal' in lt:
|
||
mult = getattr(bc, 'WEIGHT_ADJUSTMENT_FACTORS', {}).get(f'kindred_{role}', 1.0)
|
||
w *= mult
|
||
boosted_roles.add(role)
|
||
weights[role] = w
|
||
total = sum(weights.values())
|
||
if total > 1.0:
|
||
for r in list(weights):
|
||
weights[r] /= total
|
||
else:
|
||
rem = 1.0 - total
|
||
base_sum_unboosted = sum(base_map[r] for r,_t in themes_ordered if r not in boosted_roles)
|
||
if rem > 1e-6 and base_sum_unboosted > 0:
|
||
for r,_t in themes_ordered:
|
||
if r not in boosted_roles:
|
||
weights[r] += rem * (base_map[r] / base_sum_unboosted)
|
||
else:
|
||
weights['primary'] = 1.0
|
||
# Tag parsing unified via builder_utils.normalize_tag_cell
|
||
creature_df = df[df['type'].str.contains('Creature', case=False, na=False)].copy()
|
||
# Exclude commander from candidate pool to avoid duplicating it in creature additions
|
||
commander_name = getattr(self, 'commander', None) or getattr(self, 'commander_name', None)
|
||
if commander_name and 'name' in creature_df.columns:
|
||
creature_df = creature_df[creature_df['name'] != commander_name]
|
||
if creature_df.empty:
|
||
self.output_func("No creature rows in dataset; skipping.")
|
||
return
|
||
selected_tags_lower = [t.lower() for _r,t in themes_ordered]
|
||
if '_parsedThemeTags' not in creature_df.columns:
|
||
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
|
||
creature_df['_normTags'] = creature_df['_parsedThemeTags'] # already lowercase
|
||
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
|
||
base_top = 30
|
||
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
||
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
|
||
total_added = 0
|
||
added_names: list[str] = []
|
||
per_theme_added: dict[str, list[str]] = {r: [] for r,_t in themes_ordered}
|
||
for role, tag in themes_ordered:
|
||
w = weights.get(role, 0.0)
|
||
if w <= 0:
|
||
continue
|
||
remaining = max(0, desired_total - total_added)
|
||
if remaining == 0:
|
||
break
|
||
target = int(math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
|
||
target = min(target, remaining)
|
||
if target <= 0:
|
||
continue
|
||
tnorm = tag.lower()
|
||
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
|
||
if subset.empty:
|
||
self.output_func(f"Theme '{tag}' produced no creature candidates.")
|
||
continue
|
||
if 'edhrecRank' in subset.columns:
|
||
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||
elif 'manaValue' in subset.columns:
|
||
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||
pool = subset.head(top_n).copy()
|
||
pool = pool[~pool['name'].isin(added_names)]
|
||
if pool.empty:
|
||
continue
|
||
# Weighted sampling using helper
|
||
weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
|
||
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
|
||
for nm in chosen:
|
||
if commander_name and nm == commander_name:
|
||
continue # safeguard
|
||
row = pool[pool['name']==nm].iloc[0]
|
||
self.add_card(nm,
|
||
card_type=row.get('type','Creature'),
|
||
mana_cost=row.get('manaCost',''),
|
||
mana_value=row.get('manaValue', row.get('cmc','')),
|
||
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [])
|
||
added_names.append(nm)
|
||
per_theme_added[role].append(nm)
|
||
total_added += 1
|
||
if total_added >= desired_total:
|
||
break
|
||
self.output_func(f"Added {len(per_theme_added[role])} creatures for {role} theme '{tag}' (target {target}).")
|
||
if total_added >= desired_total:
|
||
break
|
||
if total_added < desired_total:
|
||
need = desired_total - total_added
|
||
multi_pool = creature_df[~creature_df['name'].isin(added_names)].copy()
|
||
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||
if not multi_pool.empty:
|
||
if 'edhrecRank' in multi_pool.columns:
|
||
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||
elif 'manaValue' in multi_pool.columns:
|
||
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||
fill = multi_pool['name'].tolist()[:need]
|
||
for nm in fill:
|
||
if commander_name and nm == commander_name:
|
||
continue
|
||
row = multi_pool[multi_pool['name']==nm].iloc[0]
|
||
self.add_card(nm,
|
||
card_type=row.get('type','Creature'),
|
||
mana_cost=row.get('manaCost',''),
|
||
mana_value=row.get('manaValue', row.get('cmc','')),
|
||
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [])
|
||
added_names.append(nm)
|
||
total_added += 1
|
||
if total_added >= desired_total:
|
||
break
|
||
self.output_func(f"Fill pass added {min(need, len(fill))} extra creatures (shortfall compensation).")
|
||
self.output_func("\nCreatures Added:")
|
||
for role, tag in themes_ordered:
|
||
lst = per_theme_added.get(role, [])
|
||
if lst:
|
||
self.output_func(f" {role.title()} '{tag}': {len(lst)}")
|
||
for nm in lst:
|
||
self.output_func(f" - {nm}")
|
||
else:
|
||
self.output_func(f" {role.title()} '{tag}': 0")
|
||
self.output_func(f" Total {total_added}/{desired_total}{' (dataset shortfall)' if total_added < desired_total else ''}")
|
||
|
||
# ---------------------------
|
||
# Non-Creature Additions (Ramp, Removal, Wipes, Draw, Protection)
|
||
# ---------------------------
|
||
def add_ramp(self):
|
||
"""Add ramp pieces in three phases: mana rocks (~1/3), mana dorks (~1/4), then general/other.
|
||
|
||
Selection is deterministic priority based: lowest edhrecRank then lowest mana value.
|
||
No theme weighting – simple best-available filtering while avoiding duplicates.
|
||
"""
|
||
if not self._combined_cards_df is not None:
|
||
return
|
||
target_total = self.ideal_counts.get('ramp', 0)
|
||
if target_total <= 0:
|
||
return
|
||
already = {n.lower() for n in self.card_library.keys()}
|
||
df = self._combined_cards_df
|
||
if 'name' not in df.columns:
|
||
return
|
||
|
||
work = df.copy()
|
||
work['_ltags'] = work.get('themeTags', []).apply(bu.normalize_tag_cell)
|
||
work = work[work['_ltags'].apply(lambda tags: any('ramp' in t for t in tags))]
|
||
if work.empty:
|
||
self.output_func('No ramp-tagged cards found in dataset.')
|
||
return
|
||
# Compute adjusted ramp target (with random bonus) via helper
|
||
existing_ramp = 0
|
||
for name, entry in self.card_library.items():
|
||
if any(isinstance(t, str) and 'ramp' in t.lower() for t in entry.get('Tags', [])):
|
||
existing_ramp += 1
|
||
to_add, _bonus = bu.compute_adjusted_target('Ramp', target_total, existing_ramp, self.output_func, plural_word='ramp spells')
|
||
if existing_ramp >= target_total and to_add == 0:
|
||
return
|
||
if existing_ramp < target_total:
|
||
target_total = to_add
|
||
else:
|
||
target_total = to_add # overflow case adds extra beyond original
|
||
# Exclude lands (handled separately) and Commander
|
||
work = work[~work['type'].fillna('').str.contains('Land', case=False, na=False)]
|
||
commander_name = getattr(self, 'commander', None)
|
||
if commander_name:
|
||
work = work[work['name'] != commander_name]
|
||
# Sort priority (lowest edhrecRank then manaValue)
|
||
work = bu.sort_by_priority(work, ['edhrecRank','manaValue'])
|
||
|
||
# Phase targets
|
||
# top-level math
|
||
rocks_target = min(target_total, math.ceil(target_total/3))
|
||
dorks_target = min(target_total - rocks_target, math.ceil(target_total/4))
|
||
# remainder auto for general
|
||
|
||
added_rocks = []
|
||
added_dorks = []
|
||
added_general = []
|
||
|
||
def add_from_pool(pool, remaining_needed, added_list, phase_name):
|
||
added_now = 0
|
||
for _, r in pool.iterrows():
|
||
nm = r['name']
|
||
if nm.lower() in already:
|
||
continue
|
||
self.add_card(nm,
|
||
card_type=r.get('type',''),
|
||
mana_cost=r.get('manaCost',''),
|
||
mana_value=r.get('manaValue', r.get('cmc','')),
|
||
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [])
|
||
already.add(nm.lower())
|
||
added_list.append(nm)
|
||
added_now += 1
|
||
if added_now >= remaining_needed:
|
||
break
|
||
if added_now:
|
||
self.output_func(f"Ramp phase {phase_name}: added {added_now}/{remaining_needed} target.")
|
||
return added_now
|
||
|
||
# Mana Rocks: Artifact ramp (artifact in type OR common rock tag)
|
||
rocks_pool = work[work['type'].fillna('').str.contains('Artifact', case=False, na=False)]
|
||
if rocks_target > 0:
|
||
add_from_pool(rocks_pool, rocks_target, added_rocks, 'Rocks')
|
||
|
||
# Mana Dorks: Creature ramp (Creature type)
|
||
dorks_pool = work[work['type'].fillna('').str.contains('Creature', case=False, na=False)]
|
||
if dorks_target > 0:
|
||
add_from_pool(dorks_pool, dorks_target, added_dorks, 'Dorks')
|
||
|
||
# General / Remaining
|
||
current_total = len(added_rocks) + len(added_dorks)
|
||
remaining = target_total - current_total
|
||
if remaining > 0:
|
||
general_pool = work[~work['name'].isin(added_rocks + added_dorks)]
|
||
add_from_pool(general_pool, remaining, added_general, 'General')
|
||
|
||
total_added_now = len(added_rocks)+len(added_dorks)+len(added_general)
|
||
self.output_func(f"Total Ramp Added This Pass: {total_added_now}/{target_total}")
|
||
if (len(added_rocks)+len(added_dorks)+len(added_general)) < target_total:
|
||
self.output_func('Ramp shortfall due to limited dataset.')
|
||
if total_added_now:
|
||
self.output_func("Ramp Cards Added:")
|
||
for nm in added_rocks:
|
||
self.output_func(f" [Rock] {nm}")
|
||
for nm in added_dorks:
|
||
self.output_func(f" [Dork] {nm}")
|
||
for nm in added_general:
|
||
self.output_func(f" [General] {nm}")
|
||
|
||
def add_removal(self):
|
||
"""Add spot removal spells up to ideal count. Excludes obvious board wipes."""
|
||
target = self.ideal_counts.get('removal', 0)
|
||
if target <= 0 or self._combined_cards_df is None:
|
||
return
|
||
already = {n.lower() for n in self.card_library.keys()}
|
||
df = self._combined_cards_df.copy()
|
||
if 'name' not in df.columns:
|
||
return
|
||
df['_ltags'] = df.get('themeTags', []).apply(bu.normalize_tag_cell)
|
||
def is_removal(tags):
|
||
return any('removal' in t or 'spot removal' in t for t in tags)
|
||
def is_wipe(tags):
|
||
return any('board wipe' in t or 'mass removal' in t for t in tags)
|
||
pool = df[df['_ltags'].apply(is_removal) & ~df['_ltags'].apply(is_wipe)]
|
||
pool = pool[~pool['type'].fillna('').str.contains('Land', case=False, na=False)]
|
||
commander_name = getattr(self, 'commander', None)
|
||
if commander_name:
|
||
pool = pool[pool['name'] != commander_name]
|
||
# Sort priority
|
||
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
|
||
# Adjusted removal target
|
||
existing = 0
|
||
for name, entry in self.card_library.items():
|
||
lt = [str(t).lower() for t in entry.get('Tags', [])]
|
||
if any(('removal' in t or 'spot removal' in t) for t in lt) and not any(('board wipe' in t or 'mass removal' in t) for t in lt):
|
||
existing += 1
|
||
to_add, _bonus = bu.compute_adjusted_target('Removal', target, existing, self.output_func, plural_word='removal spells')
|
||
if existing >= target and to_add == 0:
|
||
return
|
||
target = to_add if existing < target else to_add
|
||
added = 0
|
||
added_names = []
|
||
for _, r in pool.iterrows():
|
||
if added >= target:
|
||
break
|
||
nm = r['name']
|
||
if nm.lower() in already:
|
||
continue
|
||
self.add_card(nm,
|
||
card_type=r.get('type',''),
|
||
mana_cost=r.get('manaCost',''),
|
||
mana_value=r.get('manaValue', r.get('cmc','')),
|
||
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [])
|
||
already.add(nm.lower())
|
||
added += 1
|
||
added_names.append(nm)
|
||
self.output_func(f"Added Spot Removal This Pass: {added}/{target}{' (dataset shortfall)' if added < target else ''}")
|
||
if added_names:
|
||
self.output_func('Removal Cards Added:')
|
||
for nm in added_names:
|
||
self.output_func(f" - {nm}")
|
||
|
||
def add_board_wipes(self):
|
||
"""Add board wipe spells up to ideal count."""
|
||
target = self.ideal_counts.get('wipes', 0)
|
||
if target <= 0 or self._combined_cards_df is None:
|
||
return
|
||
already = {n.lower() for n in self.card_library.keys()}
|
||
df = self._combined_cards_df.copy()
|
||
df['_ltags'] = df.get('themeTags', []).apply(bu.normalize_tag_cell)
|
||
def is_wipe(tags):
|
||
return any('board wipe' in t or 'mass removal' in t for t in tags)
|
||
pool = df[df['_ltags'].apply(is_wipe)]
|
||
pool = pool[~pool['type'].fillna('').str.contains('Land', case=False, na=False)]
|
||
commander_name = getattr(self, 'commander', None)
|
||
if commander_name:
|
||
pool = pool[pool['name'] != commander_name]
|
||
# Sort priority
|
||
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
|
||
# Adjusted board wipe target
|
||
existing = 0
|
||
for name, entry in self.card_library.items():
|
||
tags = [str(t).lower() for t in entry.get('Tags', [])]
|
||
if any(('board wipe' in t or 'mass removal' in t) for t in tags):
|
||
existing += 1
|
||
to_add, _bonus = bu.compute_adjusted_target('Board wipe', target, existing, self.output_func, plural_word='wipes')
|
||
if existing >= target and to_add == 0:
|
||
return
|
||
target = to_add if existing < target else to_add
|
||
added = 0
|
||
added_names = []
|
||
for _, r in pool.iterrows():
|
||
if added >= target:
|
||
break
|
||
nm = r['name']
|
||
if nm.lower() in already:
|
||
continue
|
||
self.add_card(nm,
|
||
card_type=r.get('type',''),
|
||
mana_cost=r.get('manaCost',''),
|
||
mana_value=r.get('manaValue', r.get('cmc','')),
|
||
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [])
|
||
already.add(nm.lower())
|
||
added += 1
|
||
added_names.append(nm)
|
||
self.output_func(f"Added Board Wipes This Pass: {added}/{target}{' (dataset shortfall)' if added < target else ''}")
|
||
if added_names:
|
||
self.output_func('Board Wipes Added:')
|
||
for nm in added_names:
|
||
self.output_func(f" - {nm}")
|
||
|
||
def add_card_advantage(self):
|
||
"""Add card advantage (draw) in two phases: conditional (~20%) then unconditional remainder."""
|
||
total_target = self.ideal_counts.get('card_advantage', 0)
|
||
if total_target <= 0 or self._combined_cards_df is None:
|
||
return
|
||
existing = 0
|
||
for name, entry in self.card_library.items():
|
||
tags = [str(t).lower() for t in entry.get('Tags', [])]
|
||
if any(('draw' in t) or ('card advantage' in t) for t in tags):
|
||
existing += 1
|
||
to_add_total, _bonus = bu.compute_adjusted_target('Card advantage', total_target, existing, self.output_func, plural_word='draw spells')
|
||
if existing >= total_target and to_add_total == 0:
|
||
return
|
||
total_target = to_add_total if existing < total_target else to_add_total
|
||
conditional_target = min(total_target, math.ceil(total_target * 0.2))
|
||
already = {n.lower() for n in self.card_library.keys()}
|
||
df = self._combined_cards_df.copy()
|
||
df['_ltags'] = df.get('themeTags', []).apply(bu.normalize_tag_cell)
|
||
def is_draw(tags):
|
||
return any(('draw' in t) or ('card advantage' in t) for t in tags)
|
||
df = df[df['_ltags'].apply(is_draw)]
|
||
df = df[~df['type'].fillna('').str.contains('Land', case=False, na=False)]
|
||
commander_name = getattr(self, 'commander', None)
|
||
if commander_name:
|
||
df = df[df['name'] != commander_name]
|
||
# Classify conditional vs unconditional by presence of keywords
|
||
CONDITIONAL_KEYS = ['conditional', 'situational', 'attacks', 'combat damage', 'when you cast']
|
||
def is_conditional(tags):
|
||
return any(any(k in t for k in CONDITIONAL_KEYS) for t in tags)
|
||
conditional_df = df[df['_ltags'].apply(is_conditional)]
|
||
unconditional_df = df[~df.index.isin(conditional_df.index)]
|
||
def sortit(d):
|
||
return bu.sort_by_priority(d, ['edhrecRank','manaValue'])
|
||
conditional_df = sortit(conditional_df)
|
||
unconditional_df = sortit(unconditional_df)
|
||
added_cond = 0
|
||
added_cond_names = []
|
||
for _, r in conditional_df.iterrows():
|
||
if added_cond >= conditional_target:
|
||
break
|
||
nm = r['name']
|
||
if nm.lower() in already:
|
||
continue
|
||
self.add_card(nm,
|
||
card_type=r.get('type',''),
|
||
mana_cost=r.get('manaCost',''),
|
||
mana_value=r.get('manaValue', r.get('cmc','')),
|
||
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [])
|
||
already.add(nm.lower())
|
||
added_cond += 1
|
||
added_cond_names.append(nm)
|
||
remaining = total_target - added_cond
|
||
added_uncond = 0
|
||
added_uncond_names = []
|
||
if remaining > 0:
|
||
for _, r in unconditional_df.iterrows():
|
||
if added_uncond >= remaining:
|
||
break
|
||
nm = r['name']
|
||
if nm.lower() in already:
|
||
continue
|
||
self.add_card(nm,
|
||
card_type=r.get('type',''),
|
||
mana_cost=r.get('manaCost',''),
|
||
mana_value=r.get('manaValue', r.get('cmc','')),
|
||
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [])
|
||
already.add(nm.lower())
|
||
added_uncond += 1
|
||
added_uncond_names.append(nm)
|
||
self.output_func(f"Added Card Advantage This Pass: conditional {added_cond}/{conditional_target}, total {(added_cond+added_uncond)}/{total_target}{' (dataset shortfall)' if (added_cond+added_uncond) < total_target else ''}")
|
||
if added_cond_names or added_uncond_names:
|
||
self.output_func('Card Advantage Cards Added:')
|
||
for nm in added_cond_names:
|
||
self.output_func(f" [Conditional] {nm}")
|
||
for nm in added_uncond_names:
|
||
self.output_func(f" [Unconditional] {nm}")
|
||
|
||
def add_protection(self):
|
||
"""Add protection spells up to target (tags containing 'protection')."""
|
||
target = self.ideal_counts.get('protection', 0)
|
||
if target <= 0 or self._combined_cards_df is None:
|
||
return
|
||
already = {n.lower() for n in self.card_library.keys()}
|
||
df = self._combined_cards_df.copy()
|
||
df['_ltags'] = df.get('themeTags', []).apply(bu.normalize_tag_cell)
|
||
pool = df[df['_ltags'].apply(lambda tags: any('protection' in t for t in tags))]
|
||
pool = pool[~pool['type'].fillna('').str.contains('Land', case=False, na=False)]
|
||
commander_name = getattr(self, 'commander', None)
|
||
if commander_name:
|
||
pool = pool[pool['name'] != commander_name]
|
||
# Sort priority
|
||
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
|
||
existing = 0
|
||
for name, entry in self.card_library.items():
|
||
tags = [str(t).lower() for t in entry.get('Tags', [])]
|
||
if any('protection' in t for t in tags):
|
||
existing += 1
|
||
to_add, _bonus = bu.compute_adjusted_target('Protection', target, existing, self.output_func, plural_word='protection spells')
|
||
if existing >= target and to_add == 0:
|
||
return
|
||
target = to_add if existing < target else to_add
|
||
added = 0
|
||
added_names = []
|
||
for _, r in pool.iterrows():
|
||
if added >= target:
|
||
break
|
||
nm = r['name']
|
||
if nm.lower() in already:
|
||
continue
|
||
self.add_card(nm,
|
||
card_type=r.get('type',''),
|
||
mana_cost=r.get('manaCost',''),
|
||
mana_value=r.get('manaValue', r.get('cmc','')),
|
||
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [])
|
||
already.add(nm.lower())
|
||
added += 1
|
||
added_names.append(nm)
|
||
self.output_func(f"Added Protection This Pass: {added}/{target}{' (dataset shortfall)' if added < target else ''}")
|
||
if added_names:
|
||
self.output_func('Protection Cards Added:')
|
||
for nm in added_names:
|
||
self.output_func(f" - {nm}")
|
||
|
||
def fill_remaining_theme_spells(self):
|
||
"""Fill remaining deck slots (to 100 incl. commander) with non-land, non-creature theme spells.
|
||
|
||
Uses same multi-theme weighting & kindred multipliers as creature addition.
|
||
Weighted sampling favors multi-match (synergy) cards. Stops exactly at or before 100.
|
||
"""
|
||
# Determine remaining slots
|
||
total_cards = sum(entry.get('Count', 1) for entry in self.card_library.values())
|
||
remaining = 100 - total_cards
|
||
if remaining <= 0:
|
||
return
|
||
df = getattr(self, '_combined_cards_df', None)
|
||
if df is None or df.empty or 'type' not in df.columns:
|
||
return
|
||
themes_ordered: list[tuple[str, str]] = []
|
||
if self.primary_tag:
|
||
themes_ordered.append(('primary', self.primary_tag))
|
||
if self.secondary_tag:
|
||
themes_ordered.append(('secondary', self.secondary_tag))
|
||
if self.tertiary_tag:
|
||
themes_ordered.append(('tertiary', self.tertiary_tag))
|
||
if not themes_ordered:
|
||
return
|
||
# top-level ast, re, math, random
|
||
n_themes = len(themes_ordered)
|
||
if n_themes == 1:
|
||
base_map = {'primary': 1.0}
|
||
elif n_themes == 2:
|
||
base_map = {'primary': 0.6, 'secondary': 0.4}
|
||
else:
|
||
base_map = {'primary': 0.5, 'secondary': 0.3, 'tertiary': 0.2}
|
||
weights: dict[str, float] = {}
|
||
boosted: set[str] = set()
|
||
if n_themes > 1:
|
||
for role, tag in themes_ordered:
|
||
w = base_map.get(role, 0.0)
|
||
lt = tag.lower()
|
||
if 'kindred' in lt or 'tribal' in lt:
|
||
mult = getattr(bc, 'WEIGHT_ADJUSTMENT_FACTORS', {}).get(f'kindred_{role}', 1.0)
|
||
w *= mult
|
||
boosted.add(role)
|
||
weights[role] = w
|
||
tot = sum(weights.values())
|
||
if tot > 1.0:
|
||
for r in weights:
|
||
weights[r] /= tot
|
||
else:
|
||
rem = 1.0 - tot
|
||
base_sum_unboosted = sum(base_map[r] for r, _ in themes_ordered if r not in boosted)
|
||
if rem > 1e-6 and base_sum_unboosted > 0:
|
||
for r, _ in themes_ordered:
|
||
if r not in boosted:
|
||
weights[r] += rem * (base_map[r] / base_sum_unboosted)
|
||
else:
|
||
weights['primary'] = 1.0
|
||
|
||
# Tag parsing now standardized via builder_utils.normalize_tag_cell
|
||
|
||
# Filter to non-land, non-creature spells
|
||
spells_df = df[
|
||
~df['type'].str.contains('Land', case=False, na=False)
|
||
& ~df['type'].str.contains('Creature', case=False, na=False)
|
||
].copy()
|
||
if spells_df.empty:
|
||
return
|
||
selected_tags_lower = [t.lower() for _r, t in themes_ordered]
|
||
if '_parsedThemeTags' not in spells_df.columns:
|
||
spells_df['_parsedThemeTags'] = spells_df['themeTags'].apply(bu.normalize_tag_cell)
|
||
spells_df['_normTags'] = spells_df['_parsedThemeTags'] # already lowercase list
|
||
spells_df['_multiMatch'] = spells_df['_normTags'].apply(
|
||
lambda lst: sum(1 for t in selected_tags_lower if t in lst)
|
||
)
|
||
base_top = 40
|
||
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
||
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
|
||
per_theme_added: dict[str, list[str]] = {r: [] for r, _t in themes_ordered}
|
||
total_added = 0
|
||
for role, tag in themes_ordered:
|
||
if remaining - total_added <= 0:
|
||
break
|
||
w = weights.get(role, 0.0)
|
||
if w <= 0:
|
||
continue
|
||
target = int(math.ceil(remaining * w * self._get_rng().uniform(1.0, 1.1)))
|
||
target = min(target, remaining - total_added)
|
||
if target <= 0:
|
||
continue
|
||
tnorm = tag.lower()
|
||
subset = spells_df[
|
||
spells_df['_normTags'].apply(
|
||
lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst)
|
||
)
|
||
]
|
||
if subset.empty:
|
||
continue
|
||
if 'edhrecRank' in subset.columns:
|
||
subset = subset.sort_values(
|
||
by=['_multiMatch', 'edhrecRank', 'manaValue'],
|
||
ascending=[False, True, True],
|
||
na_position='last',
|
||
)
|
||
elif 'manaValue' in subset.columns:
|
||
subset = subset.sort_values(
|
||
by=['_multiMatch', 'manaValue'],
|
||
ascending=[False, True],
|
||
na_position='last',
|
||
)
|
||
pool = subset.head(top_n).copy()
|
||
pool = pool[~pool['name'].isin(self.card_library.keys())]
|
||
if pool.empty:
|
||
continue
|
||
weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
|
||
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
|
||
for nm in chosen:
|
||
row = pool[pool['name'] == nm].iloc[0]
|
||
self.add_card(
|
||
nm,
|
||
card_type=row.get('type', ''),
|
||
mana_cost=row.get('manaCost', ''),
|
||
mana_value=row.get('manaValue', row.get('cmc', '')),
|
||
tags=row.get('themeTags', [])
|
||
if isinstance(row.get('themeTags', []), list)
|
||
else [],
|
||
)
|
||
per_theme_added[role].append(nm)
|
||
total_added += 1
|
||
if total_added >= remaining:
|
||
break
|
||
if total_added < remaining:
|
||
need = remaining - total_added
|
||
multi_pool = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy()
|
||
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||
if not multi_pool.empty:
|
||
if 'edhrecRank' in multi_pool.columns:
|
||
multi_pool = multi_pool.sort_values(
|
||
by=['_multiMatch', 'edhrecRank', 'manaValue'],
|
||
ascending=[False, True, True],
|
||
na_position='last',
|
||
)
|
||
elif 'manaValue' in multi_pool.columns:
|
||
multi_pool = multi_pool.sort_values(
|
||
by=['_multiMatch', 'manaValue'],
|
||
ascending=[False, True],
|
||
na_position='last',
|
||
)
|
||
fill = multi_pool['name'].tolist()[:need]
|
||
for nm in fill:
|
||
row = multi_pool[multi_pool['name'] == nm].iloc[0]
|
||
self.add_card(
|
||
nm,
|
||
card_type=row.get('type', ''),
|
||
mana_cost=row.get('manaCost', ''),
|
||
mana_value=row.get('manaValue', row.get('cmc', '')),
|
||
tags=row.get('themeTags', [])
|
||
if isinstance(row.get('themeTags', []), list)
|
||
else [],
|
||
)
|
||
total_added += 1
|
||
if total_added >= remaining:
|
||
break
|
||
# Still short? Randomly add general utility (ramp / draw / protection / removal / wipes)
|
||
if total_added < remaining:
|
||
extra_needed = remaining - total_added
|
||
leftover = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy()
|
||
if not leftover.empty:
|
||
# Prepare lowercase tag lists if not present
|
||
if '_normTags' not in leftover.columns:
|
||
leftover['_normTags'] = leftover['themeTags'].apply(
|
||
lambda x: [str(t).lower() for t in x] if isinstance(x, list) else []
|
||
)
|
||
def has_any(tag_list, needles):
|
||
return any(any(nd in t for nd in needles) for t in tag_list)
|
||
# Category detection
|
||
def classify(row):
|
||
tags = row['_normTags']
|
||
if has_any(tags, ['ramp']):
|
||
return 'ramp'
|
||
if has_any(tags, ['card advantage', 'draw']):
|
||
return 'card_advantage'
|
||
if has_any(tags, ['protection']):
|
||
return 'protection'
|
||
if has_any(tags, ['board wipe', 'mass removal']):
|
||
return 'board_wipe'
|
||
if has_any(tags, ['removal']):
|
||
return 'removal'
|
||
return ''
|
||
leftover['_fillerCat'] = leftover.apply(classify, axis=1)
|
||
random_added = []
|
||
for _ in range(extra_needed):
|
||
candidates_by_cat = {}
|
||
for cat in ['ramp','card_advantage','protection','board_wipe','removal']:
|
||
subset = leftover[leftover['_fillerCat'] == cat]
|
||
if not subset.empty:
|
||
candidates_by_cat[cat] = subset
|
||
if not candidates_by_cat:
|
||
# fallback: any leftover spell
|
||
subset = leftover
|
||
else:
|
||
cat_choice = self._get_rng().choice(list(candidates_by_cat.keys()))
|
||
subset = candidates_by_cat[cat_choice]
|
||
# Sort subset
|
||
if 'edhrecRank' in subset.columns:
|
||
subset = subset.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
|
||
elif 'manaValue' in subset.columns:
|
||
subset = subset.sort_values(by=['manaValue'], ascending=[True], na_position='last')
|
||
row = subset.head(1)
|
||
if row.empty:
|
||
break
|
||
r0 = row.iloc[0]
|
||
nm = r0['name']
|
||
self.add_card(
|
||
nm,
|
||
card_type=r0.get('type',''),
|
||
mana_cost=r0.get('manaCost',''),
|
||
mana_value=r0.get('manaValue', r0.get('cmc','')),
|
||
tags=r0.get('themeTags', []) if isinstance(r0.get('themeTags', []), list) else []
|
||
)
|
||
random_added.append(nm)
|
||
# Remove from leftover
|
||
leftover = leftover[leftover['name'] != nm]
|
||
total_added += 1
|
||
if total_added >= remaining:
|
||
break
|
||
if random_added:
|
||
self.output_func(" General Utility Filler Added:")
|
||
for nm in random_added:
|
||
self.output_func(f" - {nm}")
|
||
if total_added:
|
||
self.output_func("\nFinal Theme Spell Fill:")
|
||
for role, tag in themes_ordered:
|
||
lst = per_theme_added.get(role, [])
|
||
if lst:
|
||
self.output_func(f" {role.title()} '{tag}': {len(lst)}")
|
||
for nm in lst:
|
||
self.output_func(f" - {nm}")
|
||
self.output_func(f" Total Theme Spells Added: {total_added}")
|
||
|
||
def add_non_creature_spells(self):
|
||
"""Convenience orchestrator for adding remaining non-creature spell categories in standard order."""
|
||
self.add_ramp()
|
||
self.add_removal()
|
||
self.add_board_wipes()
|
||
self.add_card_advantage()
|
||
self.add_protection()
|
||
# Final thematic fill to 100
|
||
self.fill_remaining_theme_spells()
|
||
# Show card type distribution after all spells
|
||
self.print_type_summary()
|
||
|
||
def print_type_summary(self):
|
||
"""Print a concise summary of deck counts by primary card types (includes commander)."""
|
||
type_buckets = {
|
||
'Lands': 0,
|
||
'Creatures': 0,
|
||
'Artifacts': 0,
|
||
'Enchantments': 0,
|
||
'Instants': 0,
|
||
'Sorceries': 0,
|
||
'Planeswalkers': 0,
|
||
'Battles': 0,
|
||
'Other': 0,
|
||
}
|
||
for name, entry in self.card_library.items():
|
||
ctype = str(entry.get('Card Type', ''))
|
||
count = entry.get('Count', 1)
|
||
low = ctype.lower()
|
||
placed = False
|
||
if 'land' in low:
|
||
type_buckets['Lands'] += count
|
||
placed = True
|
||
if 'creature' in low:
|
||
type_buckets['Creatures'] += count
|
||
placed = True
|
||
if 'artifact' in low:
|
||
type_buckets['Artifacts'] += count
|
||
placed = True
|
||
if 'enchantment' in low:
|
||
type_buckets['Enchantments'] += count
|
||
placed = True
|
||
if 'instant' in low:
|
||
type_buckets['Instants'] += count
|
||
placed = True
|
||
if 'sorcery' in low:
|
||
type_buckets['Sorceries'] += count
|
||
placed = True
|
||
if 'planeswalker' in low:
|
||
type_buckets['Planeswalkers'] += count
|
||
placed = True
|
||
if 'battle' in low:
|
||
type_buckets['Battles'] += count
|
||
placed = True
|
||
if not placed:
|
||
type_buckets['Other'] += count
|
||
total = sum(type_buckets.values())
|
||
self.output_func("\nCard Type Summary:")
|
||
for k in ['Lands','Creatures','Artifacts','Enchantments','Instants','Sorceries','Planeswalkers','Battles','Other']:
|
||
v = type_buckets[k]
|
||
if v:
|
||
self.output_func(f" {k}: {v}")
|
||
deck_card_total = sum(entry.get('Count',1) for entry in self.card_library.values())
|
||
self.output_func(f" Total (multi-type bucketed sum): {total}")
|
||
if total != deck_card_total:
|
||
self.output_func(f" Unique Card Copies: {deck_card_total}")
|
||
self.output_func(" Note: Multi-typed cards (e.g., Artifact Creature) are counted in each applicable bucket.")
|
||
|
||
# ---------------------------
|
||
# Card Library Reporting
|
||
# ---------------------------
|
||
def export_decklist_csv(self, path: Optional[str] = None) -> Optional[str]:
|
||
"""Export current deck list to CSV file.
|
||
|
||
If path not provided, writes to deck_files/<CommanderFirstWord>_<YYYYMMDD>.csv.
|
||
Returns the written path or None on failure.
|
||
"""
|
||
if not self.card_library:
|
||
self.output_func('No cards in library to export.')
|
||
return None
|
||
commander_name = getattr(self, 'commander', '') or ''
|
||
if path is None:
|
||
first = 'deck'
|
||
if commander_name:
|
||
first = commander_name.split()[0]
|
||
date_str = datetime.date.today().strftime('%Y%m%d')
|
||
os.makedirs('deck_files', exist_ok=True)
|
||
path = f'deck_files/{first}_{date_str}.csv'
|
||
try:
|
||
with open(path, 'w', newline='', encoding='utf-8') as f:
|
||
w = csv.writer(f)
|
||
w.writerow(['Name','Count','Type','Mana Cost','Mana Value','Tags','Text'])
|
||
# Build lookup for text from combined/full df
|
||
combined = self._full_cards_df if self._full_cards_df is not None else self._combined_cards_df
|
||
text_lookup = {}
|
||
if combined is not None and 'name' in combined.columns:
|
||
for _, r in combined.iterrows():
|
||
nm = str(r.get('name'))
|
||
if nm not in text_lookup:
|
||
txt = r.get('text', '') or r.get('oracleText', '') or ''
|
||
text_lookup[nm] = txt
|
||
for name, entry in self.card_library.items():
|
||
text_field = text_lookup.get(name, '')
|
||
w.writerow([
|
||
name,
|
||
entry.get('Count',1),
|
||
entry.get('Card Type',''),
|
||
entry.get('Mana Cost',''),
|
||
entry.get('Mana Value',''),
|
||
'; '.join(entry.get('Tags', [])),
|
||
text_field.replace('\n',' ')[:500]
|
||
])
|
||
self.output_func(f'Deck exported to {path}')
|
||
return path
|
||
except Exception as e:
|
||
self.output_func(f'Failed to export deck CSV: {e}')
|
||
return None
|
||
|
||
def print_card_library(self, truncate_text: bool = True, text_limit: int = 80):
|
||
"""Pretty print the current card library using PrettyTable.
|
||
|
||
Columns: Name | Color Identity | Colors | Mana Cost | Mana Value | Type | Creature Types | Power | Toughness | Keywords | Theme Tags | Text
|
||
Commander appears first, then cards in insertion order. Shows total & remaining slots (to 100).
|
||
"""
|
||
total_cards = sum(entry.get('Count', 1) for entry in self.card_library.values())
|
||
remaining = max(0, 100 - total_cards)
|
||
self.output_func(f"\nCard Library: {total_cards} cards (Commander included). Remaining slots: {remaining}")
|
||
|
||
try:
|
||
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():
|
||
self.output_func(f"- {name} x{entry.get('Count',1)}")
|
||
return
|
||
|
||
cols = [
|
||
'Name', 'Color Identity', 'Colors', 'Mana Cost', 'Mana Value', 'Type',
|
||
'Creature Types', 'Power', 'Toughness', 'Keywords', 'Theme Tags', 'Text'
|
||
] # 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
|
||
combined_lookup: Dict[str, pd.Series] = {}
|
||
if combined is not None and 'name' in combined.columns:
|
||
for _, r in combined.iterrows():
|
||
nm = str(r.get('name'))
|
||
if nm not in combined_lookup:
|
||
combined_lookup[nm] = r
|
||
|
||
def limit(txt: str):
|
||
if not truncate_text or txt is None:
|
||
return txt
|
||
if len(txt) <= text_limit:
|
||
return txt
|
||
return txt[: text_limit - 3] + '...'
|
||
|
||
# Commander first
|
||
ordered_items = list(self.card_library.items())
|
||
ordered_items.sort(key=lambda kv: (0 if kv[1].get('Commander') else 1))
|
||
|
||
basic_names = set(getattr(bc, 'BASIC_LANDS', []))
|
||
snow_basic_names = set(getattr(bc, 'SNOW_BASIC_LAND_MAPPING', {}).values())
|
||
rev_basic = {v: k for k, v in getattr(bc, 'COLOR_TO_BASIC_LAND', {}).items()}
|
||
rev_snow = {v: k for k, v in getattr(bc, 'SNOW_BASIC_LAND_MAPPING', {}).items()}
|
||
|
||
for name, entry in ordered_items:
|
||
row_source = combined_lookup.get(name)
|
||
count = entry.get('Count', 1)
|
||
display_name = f"{name} x{count}" if count > 1 else name
|
||
|
||
if entry.get('Commander') and self.commander_dict:
|
||
ci_list = self.commander_dict.get('Color Identity', [])
|
||
ci = ''.join(ci_list) if isinstance(ci_list, list) else str(ci_list)
|
||
colors_list = self.commander_dict.get('Colors', [])
|
||
colors = ''.join(colors_list) if isinstance(colors_list, list) else str(colors_list)
|
||
mana_cost = self.commander_dict.get('Mana Cost', '')
|
||
mana_value = self.commander_dict.get('Mana Value', '')
|
||
type_line = self.commander_dict.get('Type', '')
|
||
creature_types_val = self.commander_dict.get('Creature Types', [])
|
||
creature_types = ', '.join(creature_types_val) if isinstance(creature_types_val, list) else str(creature_types_val)
|
||
power = self.commander_dict.get('Power', '')
|
||
toughness = self.commander_dict.get('Toughness', '')
|
||
# Enrich keywords from snapshot if present
|
||
if row_source is not None:
|
||
kw_val = row_source.get('keywords', [])
|
||
if isinstance(kw_val, list):
|
||
keywords = ', '.join(str(x) for x in kw_val)
|
||
else:
|
||
keywords = '' if kw_val in (None, '') else str(kw_val)
|
||
else:
|
||
keywords = ''
|
||
theme_tags_val = self.commander_dict.get('Themes', [])
|
||
theme_tags = ', '.join(theme_tags_val) if isinstance(theme_tags_val, list) else str(theme_tags_val)
|
||
text_field = limit(self.commander_dict.get('Text', ''))
|
||
else:
|
||
# Default blanks
|
||
ci = colors = mana_cost = ''
|
||
mana_value = ''
|
||
type_line = entry.get('Card Type', '')
|
||
creature_types = power = toughness = keywords = theme_tags = text_field = ''
|
||
if row_source is not None:
|
||
# Basic enrichment fields
|
||
mana_cost = row_source.get('manaCost', '')
|
||
mana_value = row_source.get('manaValue', row_source.get('cmc', ''))
|
||
type_line = row_source.get('type', row_source.get('type_line', type_line or ''))
|
||
ct_raw = row_source.get('creatureTypes', [])
|
||
if isinstance(ct_raw, list):
|
||
creature_types = ', '.join(ct_raw)
|
||
else:
|
||
creature_types = str(ct_raw) if ct_raw not in (None, '') else ''
|
||
power = row_source.get('power', '')
|
||
toughness = row_source.get('toughness', '')
|
||
kw_raw = row_source.get('keywords', [])
|
||
if isinstance(kw_raw, list):
|
||
keywords = ', '.join(kw_raw)
|
||
elif kw_raw not in (None, ''):
|
||
keywords = str(kw_raw)
|
||
tg_raw = row_source.get('themeTags', [])
|
||
if isinstance(tg_raw, list):
|
||
theme_tags = ', '.join(tg_raw)
|
||
text_field = limit(str(row_source.get('text', row_source.get('oracleText', ''))).replace('\n', ' '))
|
||
|
||
# Only apply color identity/colors if NOT a land or is a basic/snow basic
|
||
type_lower = str(type_line).lower()
|
||
if 'land' in type_lower:
|
||
if name in basic_names:
|
||
letter = rev_basic.get(name, '')
|
||
ci = letter
|
||
colors = letter
|
||
elif name in snow_basic_names:
|
||
letter = rev_snow.get(name, '')
|
||
ci = letter
|
||
colors = letter
|
||
else:
|
||
ci = ''
|
||
colors = ''
|
||
else:
|
||
ci_raw = row_source.get('colorIdentity', row_source.get('colors', []))
|
||
if isinstance(ci_raw, list):
|
||
ci = ''.join(ci_raw)
|
||
else:
|
||
ci = str(ci_raw) if ci_raw not in (None, '') else ''
|
||
colors_raw = row_source.get('colors', [])
|
||
if isinstance(colors_raw, list):
|
||
colors = ''.join(colors_raw)
|
||
elif colors_raw not in (None, ''):
|
||
colors = str(colors_raw)
|
||
else:
|
||
# No row source (likely a basic we added or manual staple missing from CSV)
|
||
type_line = type_line or 'Land'
|
||
if name in basic_names:
|
||
letter = rev_basic.get(name, '')
|
||
ci = letter
|
||
colors = letter
|
||
elif name in snow_basic_names:
|
||
letter = rev_snow.get(name, '')
|
||
ci = letter
|
||
colors = letter
|
||
elif 'land' in str(type_line).lower():
|
||
ci = colors = '' # nonbasic land => blank
|
||
|
||
# Ensure nonbasic land override even if CSV has color identity
|
||
if 'land' in str(type_line).lower() and name not in basic_names and name not in snow_basic_names:
|
||
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 (top-level 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,
|
||
self._wrap_cell(type_line, width=30),
|
||
creature_types,
|
||
power,
|
||
toughness,
|
||
keywords,
|
||
self._wrap_cell(theme_tags, width=60),
|
||
self._wrap_cell(text_field, prefer_long=True)
|
||
])
|
||
|
||
self.output_func(table.get_string())
|
||
|
||
# Tag summary (unique card counts per tag)
|
||
if self.tag_counts:
|
||
self.output_func("\nTag Summary (unique cards per tag):")
|
||
|
||
def _clean_tag_key(tag: str) -> str:
|
||
# top-level re
|
||
if not isinstance(tag, str):
|
||
tag = str(tag)
|
||
s = tag.strip()
|
||
# Remove common leading list artifacts like [', [" or ['
|
||
s = re.sub(r"^\[+['\"]?", "", s)
|
||
# Remove common trailing artifacts like '], ] or '] etc.
|
||
s = re.sub(r"['\"]?\]+$", "", s)
|
||
# Strip stray quotes again
|
||
s = s.strip("'\"")
|
||
# Collapse internal excessive whitespace
|
||
s = ' '.join(s.split())
|
||
return s
|
||
|
||
# Aggregate counts by cleaned key to merge duplicates created by formatting artifacts
|
||
aggregated: Dict[str, int] = {}
|
||
for raw_tag, cnt in self.tag_counts.items():
|
||
cleaned = _clean_tag_key(raw_tag)
|
||
if not cleaned:
|
||
continue
|
||
aggregated[cleaned] = aggregated.get(cleaned, 0) + cnt
|
||
|
||
min_count = getattr(bc, 'TAG_SUMMARY_MIN_COUNT', 1)
|
||
always_show_subs = [s.lower() for s in getattr(bc, 'TAG_SUMMARY_ALWAYS_SHOW_SUBSTRS', [])]
|
||
printed = 0
|
||
hidden = 0
|
||
for tag, cnt in sorted(aggregated.items(), key=lambda kv: (-kv[1], kv[0].lower())):
|
||
tag_l = tag.lower()
|
||
force_show = any(sub in tag_l for sub in always_show_subs) if always_show_subs else False
|
||
if cnt >= min_count or force_show:
|
||
self.output_func(f" {tag}: {cnt}{' (low freq)' if force_show and cnt < min_count else ''}")
|
||
printed += 1
|
||
else:
|
||
hidden += 1
|
||
if hidden:
|
||
self.output_func(f" (+ {hidden} low-frequency tags hidden < {min_count})")
|
||
|
||
# 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)
|
||
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()
|