Implemented logic for adding creatures by themes, with weighted values. Added logic for keeping track of how many cards with what themes have been added as well

This commit is contained in:
mwisnowski 2025-08-19 10:18:53 -07:00
parent 411f042af8
commit 8478bc2534
4 changed files with 706 additions and 329 deletions

View file

@ -168,6 +168,10 @@ class DeckBuilder:
# 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
@ -588,8 +592,38 @@ class DeckBuilder:
mana_value = None
entry = self.card_library.get(card_name)
if entry:
# Increment only count; tag counts track unique card presence so unchanged
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,
@ -601,6 +635,11 @@ class DeckBuilder:
'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:
@ -625,6 +664,301 @@ class DeckBuilder:
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 (bracketagnostic 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
# ---------------------------
@ -1104,7 +1438,6 @@ class DeckBuilder:
"""
import math
try:
from . import builder_constants as bc
return max(0, int(math.ceil(bc.BASIC_FLOOR_FACTOR * float(min_basic_cfg))))
except Exception:
return max(0, min_basic_cfg)
@ -2049,300 +2382,210 @@ class DeckBuilder:
from . import builder_utils as bu
bu.enforce_land_cap(self, step_label)
# ---------------------------
# 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
# Non-Land Addition: Creatures
# ===========================
ideal_counts: Dict[str, int] = field(default_factory=dict)
def add_creatures(self):
"""Add creature cards distributed across selected themes (1-3).
def run_deck_build_step2(self) -> Dict[str, int]:
"""Determine ideal counts for general card categories (bracketagnostic 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
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.
"""
# 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.")
import re
import ast
import math
import random
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
# 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]}")
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
def _parse_theme_tags(val) -> list[str]:
if isinstance(val, list):
out: list[str] = []
for v in val:
if isinstance(v, list):
out.extend(str(x) for x in v)
else:
out.append(str(v))
return [s.strip() for s in out if s and s.strip()]
if isinstance(val, str):
s = val.strip()
try:
parsed = ast.literal_eval(s)
if isinstance(parsed, list):
return [str(x).strip() for x in parsed if str(x).strip()]
except Exception:
pass
if s.startswith('[') and s.endswith(']'):
s = s[1:-1]
parts = [p.strip().strip("'\"") for p in s.split(',')]
cleaned = []
for p in parts:
if not p:
continue
q = re.sub(r"^[\[\s']+|[\]\s']+$", '', p)
if q:
cleaned.append(q)
return cleaned
return []
creature_df = df[df['type'].str.contains('Creature', case=False, na=False)].copy()
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(_parse_theme_tags)
creature_df['_normTags'] = creature_df['_parsedThemeTags'].apply(lambda lst: [s.lower() for s in lst])
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 * random.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
weights_vec = [synergy_bonus if mm >= 2 else 1.0 for mm in pool['_multiMatch']]
names_vec = pool['name'].tolist()
chosen: list[str] = []
try:
for _ in range(min(target, len(names_vec))):
totw = sum(weights_vec)
if totw <= 0:
break
r = random.random() * totw
acc = 0.0
idx = 0
for i, wv in enumerate(weights_vec):
acc += wv
if r <= acc:
idx = i
break
chosen.append(names_vec.pop(idx))
weights_vec.pop(idx)
except Exception:
chosen = names_vec[:target]
for nm in chosen:
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:
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 ''}")
# ---------------------------
# Card Library Reporting
@ -2578,6 +2821,48 @@ class DeckBuilder:
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:
import 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.

View file

@ -28,6 +28,13 @@ COMMANDER_CREATURE_TYPES_DEFAULT: Final[str] = ''
COMMANDER_TAGS_DEFAULT: Final[List[str]] = []
COMMANDER_THEMES_DEFAULT: Final[List[str]] = []
# Reporting / summaries
TAG_SUMMARY_MIN_COUNT: Final[int] = 3 # Minimum unique-card count for a tag to appear in tag summary output
TAG_SUMMARY_ALWAYS_SHOW_SUBSTRS: Final[List[str]] = [
'board wipe', # ensure board wipes always shown even if below threshold
'mass removal' # common alternate phrasing
]
CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery',
'Kindred', 'Dungeon', 'Battle']
@ -357,7 +364,7 @@ LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS]
# Other defaults
DEFAULT_CREATURE_COUNT: Final[int] = 25 # Default number of creatures
DEFAULT_CREATURE_COUNT: Final[int] = 30 # Default number of creatures
DEFAULT_REMOVAL_COUNT: Final[int] = 10 # Default number of spot removal spells
DEFAULT_WIPES_COUNT: Final[int] = 2 # Default number of board wipes

View file

@ -17,6 +17,53 @@ from . import builder_constants as bc
COLOR_LETTERS = ['W', 'U', 'B', 'R', 'G']
def parse_theme_tags(val) -> list[str]:
"""Robustly parse a themeTags cell that may be a list, nested list, or string-repr.
Handles formats like:
['Tag1', 'Tag2']
"['Tag1', 'Tag2']"
Tag1, Tag2
Returns list of stripped string tags (may be empty)."""
import ast
if isinstance(val, list):
flat: list[str] = []
for v in val:
if isinstance(v, list):
flat.extend(str(x) for x in v)
else:
flat.append(str(v))
return [s.strip() for s in flat if s and str(s).strip()]
if isinstance(val, str):
s = val.strip()
# Try literal list first
try:
parsed = ast.literal_eval(s)
if isinstance(parsed, list):
return [str(x).strip() for x in parsed if str(x).strip()]
except Exception:
pass
# Fallback comma split
if s.startswith('[') and s.endswith(']'):
s = s[1:-1]
parts = [p.strip().strip("'\"") for p in s.split(',')]
out: list[str] = []
for p in parts:
if not p:
continue
clean = re.sub(r"^[\[\s']+|[\]\s']+$", '', p)
if clean:
out.append(clean)
return out
return []
def normalize_theme_list(raw) -> list[str]:
"""Parse then lowercase + strip each tag."""
tags = parse_theme_tags(raw)
return [t.lower().strip() for t in tags if t and t.strip()]
def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[str, Dict[str, int]]:
"""Build a matrix mapping land name -> {color: 0/1} indicating if that land
can (reliably) produce each color.
@ -108,6 +155,8 @@ def compute_spell_pip_weights(card_library: Dict[str, dict], color_identity: Ite
__all__ = [
'compute_color_source_matrix',
'compute_spell_pip_weights',
'parse_theme_tags',
'normalize_theme_list',
'COLOR_LETTERS',
'tapped_land_penalty',
'replacement_land_score',

View file

@ -1,23 +1,50 @@
from deck_builder.builder import DeckBuilder
# Non-interactive harness: chooses specified commander, first tag, first bracket, accepts defaults
"""Non-interactive harness.
def run(command_name: str = "Rocco, Street Chef"):
scripted_inputs = []
# Commander query
scripted_inputs.append(command_name) # initial query
# After showing matches, choose first candidate (index 1)
scripted_inputs.append("1") # select first candidate to inspect
scripted_inputs.append("y") # confirm commander
# Tag selection: choose eleventh tag as primary
scripted_inputs.append("11")
# Stop after primary (secondary prompt enters 1)
scripted_inputs.append("1")
# Stop after primary (tertiary prompt enters 0)
scripted_inputs.append("0")
# Bracket selection: choose 3 (Typical Casual mid default) else 2 maybe; pick 3
Features:
- Script commander selection.
- Script primary / optional secondary / tertiary tags.
- Apply bracket & accept default ideal counts.
- Invoke multi-theme creature addition if available (fallback to primary-only).
Use run(..., secondary_choice=2, tertiary_choice=3, use_multi_theme=True) to exercise multi-theme logic.
Indices correspond to the numbered tag list presented during interaction.
"""
def run(
command_name: str = "Finneas, Ace Archer",
add_creatures: bool = True,
use_multi_theme: bool = True,
primary_choice: int = 11,
secondary_choice: int | None = None,
tertiary_choice: int | None = None,
add_lands: bool = True,
fetch_count: int | None = 3,
dual_count: int | None = None,
triple_count: int | None = None,
utility_count: int | None = None,
):
scripted_inputs: list[str] = []
# Commander query & selection
scripted_inputs.append(command_name) # initial query
scripted_inputs.append("1") # choose first search match to inspect
scripted_inputs.append("y") # confirm commander
# Primary tag selection
scripted_inputs.append(str(primary_choice))
# Secondary tag selection or stop (0)
if secondary_choice is not None:
scripted_inputs.append(str(secondary_choice))
# Tertiary tag selection or stop (0)
if tertiary_choice is not None:
scripted_inputs.append(str(tertiary_choice))
else:
scripted_inputs.append("0")
else:
scripted_inputs.append("0") # stop at primary
# Bracket (meta power / style) selection; keeping existing scripted value
scripted_inputs.append("5")
# Ideal counts prompts (8 prompts) -> press Enter (empty) to accept defaults
# Ideal count prompts (press Enter for defaults)
for _ in range(8):
scripted_inputs.append("")
@ -26,28 +53,37 @@ def run(command_name: str = "Rocco, Street Chef"):
return scripted_inputs.pop(0)
raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt)
b = DeckBuilder(input_func=scripted_input)
b.run_initial_setup()
b.run_deck_build_step1()
b.run_deck_build_step2()
b.run_land_step1()
b.run_land_step2()
# Land Step 3: Kindred lands (if applicable)
b.run_land_step3()
# Land Step 4: Fetch lands (request exactly 3)
b.run_land_step4(requested_count=3)
# Land Step 5: Dual lands (use default desired)
b.run_land_step5()
# Land Step 6: Triple lands (use default desired 1-2)
b.run_land_step6()
# Land Step 7: Misc utility lands
b.run_land_step7()
# Land Step 8: Optimize tapped lands
b.run_land_step8()
b.print_card_library()
# Run post-spell (currently just analysis since spells not added in this harness)
b.post_spell_land_adjust()
return b
builder = DeckBuilder(input_func=scripted_input)
builder.run_initial_setup()
builder.run_deck_build_step1()
builder.run_deck_build_step2()
# Land sequence (optional)
if add_lands:
if hasattr(builder, 'run_land_step1'):
builder.run_land_step1() # Basics / initial
if hasattr(builder, 'run_land_step2'):
builder.run_land_step2() # Utility basics / rebalancing
if hasattr(builder, 'run_land_step3'):
builder.run_land_step3() # Kindred lands if applicable
if hasattr(builder, 'run_land_step4'):
builder.run_land_step4(requested_count=fetch_count)
if hasattr(builder, 'run_land_step5'):
builder.run_land_step5(requested_count=dual_count)
if hasattr(builder, 'run_land_step6'):
builder.run_land_step6(requested_count=triple_count)
if hasattr(builder, 'run_land_step7'):
builder.run_land_step7(requested_count=utility_count)
if hasattr(builder, 'run_land_step8'):
builder.run_land_step8()
if add_creatures:
builder.add_creatures()
builder.print_card_library()
builder.post_spell_land_adjust()
return builder
if __name__ == "__main__":
run()