mtg_python_deckbuilder/code/deck_builder/phases/phase3_creatures.py
matt bff64de370 fix: systematically handle numpy arrays from Parquet files across codebase
- Add ensure_theme_tags_list() utility to builder_utils for simpler numpy array handling
- Update phase3_creatures.py: 6 locations now use bu.ensure_theme_tags_list()
- Update phase4_spells.py: 9 locations now use bu.ensure_theme_tags_list()
- Update tagger.py: 2 locations use hasattr/list() for numpy compatibility
- Update extract_themes.py: 2 locations use hasattr/list() for numpy compatibility
- Fix build-similarity-cache.yml verification script to handle numpy arrays
- Enhance workflow debug output to show complete row data

Parquet files return numpy.ndarray objects for array columns, not Python lists.
The M4 migration added numpy support to canonical parse_theme_tags() in builder_utils,
but many parts of the codebase still used isinstance(list) checks that fail with arrays.
This commit systematically replaces all 19 instances with proper numpy array handling.

Fixes GitHub Actions workflow 'RuntimeError: No theme tags found' and verification failures.
2025-10-18 22:47:09 -07:00

686 lines
36 KiB
Python

from __future__ import annotations
import math
from typing import List, Dict
from .. import builder_constants as bc
from .. import builder_utils as bu
from ..theme_context import annotate_theme_matches
import logging_util
logger = logging_util.logging.getLogger(__name__)
class CreatureAdditionMixin:
"""Phase 3: Creature addition logic extracted from monolithic builder.
Responsibilities:
- Determine per-theme allocation weights (1-3 themes supported)
- Apply kindred/tribal multipliers when multiple themes selected
- Prioritize cards matching multiple selected themes
- Avoid duplicating the commander
- Deterministic weighted sampling via builder_utils helper
"""
def add_creatures(self):
"""Add creatures to the deck based on selected themes and allocation weights.
Applies kindred/tribal multipliers, prioritizes multi-theme matches, and avoids commander duplication.
Uses weighted sampling for selection and fills shortfall if needed.
"""
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
try:
context = self.get_theme_context() # type: ignore[attr-defined]
except Exception:
context = None
if context is None or not getattr(context, 'ordered_targets', []):
self.output_func("No themes selected; skipping creature addition.")
return
themes_ordered = list(context.ordered_targets)
selected_tags_lower = context.selected_slugs()
if not themes_ordered or not selected_tags_lower:
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)
weights: Dict[str, float] = dict(getattr(context, 'weights', {}))
creature_df = df[df['type'].str.contains('Creature', case=False, na=False)].copy()
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
creature_df = annotate_theme_matches(creature_df, context)
selected_tags_lower = context.selected_slugs()
combine_mode = context.combine_mode
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] = []
# AND pre-pass: pick creatures that hit all selected themes first (if 2+ themes)
all_theme_added: List[tuple[str, List[str]]] = []
if combine_mode == 'AND' and len(selected_tags_lower) >= 2:
all_cnt = len(selected_tags_lower)
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
remaining_capacity = max(0, desired_total - total_added)
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
if target_cap > 0:
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
subset_all = subset_all[~subset_all['name'].isin(added_names)]
if not subset_all.empty:
if 'edhrecRank' in subset_all.columns:
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset_all.columns:
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
# Bias owned names ahead before weighting
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
weighted_pool = []
bonus = getattr(context, 'match_bonus', 0.0)
user_matches = subset_all['_userMatch'] if '_userMatch' in subset_all.columns else None
names_list = subset_all['name'].tolist()
for idx, nm in enumerate(names_list):
w = weight_strong
if owned_lower and str(nm).lower() in owned_lower:
w *= owned_mult
if user_matches is not None:
try:
u_count = max(0.0, float(user_matches.iloc[idx]))
except Exception:
u_count = 0.0
if bonus > 1e-9 and u_count > 0:
w *= (1.0 + bonus * u_count)
weighted_pool.append((nm, w))
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap, rng=getattr(self, 'rng', None))
for nm in chosen_all:
if commander_name and nm == commander_name:
continue
row = subset_all[subset_all['name'] == nm].iloc[0]
# Which selected themes does this card hit?
hits = row.get('_matchTags', [])
if not isinstance(hits, list):
try:
hits = list(hits)
except Exception:
hits = []
match_score = row.get('_matchScore', row.get('_multiMatch', all_cnt))
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=bu.ensure_theme_tags_list(row.get('themeTags')),
role='creature',
sub_role='all_theme',
added_by='creature_all_theme',
trigger_tag=", ".join(hits) if hits else None,
synergy=int(round(match_score)) if match_score is not None else int(row.get('_multiMatch', all_cnt))
)
added_names.append(nm)
all_theme_added.append((nm, hits))
total_added += 1
if total_added >= desired_total:
break
self.output_func(f"All-Theme AND Pre-Pass: added {len(all_theme_added)} / {target_cap} (matching all {all_cnt} themes)")
# Per-theme distribution
per_theme_added: Dict[str, List[str]] = {target.role: [] for target in themes_ordered}
for target in themes_ordered:
role = target.role
tag = target.display
slug = target.slug or (str(tag).lower() if tag else "")
w = weights.get(role, target.weight if hasattr(target, 'weight') else 0.0)
if w <= 0:
continue
remaining = max(0, desired_total - total_added)
if remaining == 0:
break
target_count = int(math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
target_count = min(target_count, remaining)
if target_count <= 0:
continue
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=slug: (tn in lst) or any(tn in (item or '') for item in lst))]
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
if (creature_df['_multiMatch'] >= 2).any():
subset = subset[subset['_multiMatch'] >= 2]
if subset.empty:
self.output_func(f"Theme '{tag}' produced no creature candidates.")
continue
sort_cols: List[str] = []
asc: List[bool] = []
if '_matchScore' in subset.columns:
sort_cols.append('_matchScore')
asc.append(False)
sort_cols.append('_multiMatch')
asc.append(False)
if 'edhrecRank' in subset.columns:
sort_cols.append('edhrecRank')
asc.append(True)
if 'manaValue' in subset.columns:
sort_cols.append('manaValue')
asc.append(True)
subset = subset.sort_values(by=sort_cols, ascending=asc, na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
pool = subset.head(top_n).copy()
pool = pool[~pool['name'].isin(added_names)]
if pool.empty:
continue
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
bonus = getattr(context, 'match_bonus', 0.0)
if combine_mode == 'AND':
weighted_pool = []
for idx, nm in enumerate(pool['name']):
mm = pool.iloc[idx].get('_matchScore', pool.iloc[idx].get('_multiMatch', 0))
try:
mm_val = float(mm)
except Exception:
mm_val = 0.0
base_w = (synergy_bonus * 1.3 if mm_val >= 2 else (1.1 if mm_val >= 1 else 0.8))
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
if bonus > 1e-9:
try:
u_match = float(pool.iloc[idx].get('_userMatch', 0))
except Exception:
u_match = 0.0
if u_match > 0:
base_w *= (1.0 + bonus * u_match)
weighted_pool.append((nm, base_w))
else:
weighted_pool = []
for idx, nm in enumerate(pool['name']):
mm = pool.iloc[idx].get('_matchScore', pool.iloc[idx].get('_multiMatch', 0))
try:
mm_val = float(mm)
except Exception:
mm_val = 0.0
base_w = (synergy_bonus if mm_val >= 2 else 1.0)
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
if bonus > 1e-9:
try:
u_match = float(pool.iloc[idx].get('_userMatch', 0))
except Exception:
u_match = 0.0
if u_match > 0:
base_w *= (1.0 + bonus * u_match)
weighted_pool.append((nm, base_w))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target_count, rng=getattr(self, 'rng', None))
for nm in chosen:
if commander_name and nm == commander_name:
continue
row = pool[pool['name']==nm].iloc[0]
match_score = row.get('_matchScore', row.get('_multiMatch', 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=bu.ensure_theme_tags_list(row.get('themeTags')),
role='creature',
sub_role=role,
added_by='creature_add',
trigger_tag=tag,
synergy=int(round(match_score)) if match_score is not None else int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
added_names.append(nm)
per_theme_added[role].append(nm)
total_added += 1
if total_added >= desired_total:
break
source_label = 'User' if target.source == 'user' else role.title()
self.output_func(f"Added {len(per_theme_added[role])} creatures for {source_label} theme '{tag}' (target {target_count}).")
if total_added >= desired_total:
break
# Fill remaining if still short
if total_added < desired_total:
need = desired_total - total_added
multi_pool = creature_df[~creature_df['name'].isin(added_names)].copy()
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
prioritized = multi_pool[multi_pool['_multiMatch'] >= 2]
if prioritized.empty:
prioritized = multi_pool[multi_pool['_multiMatch'] > 0]
multi_pool = prioritized
else:
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if not multi_pool.empty:
sort_cols: List[str] = []
asc: List[bool] = []
if '_matchScore' in multi_pool.columns:
sort_cols.append('_matchScore')
asc.append(False)
sort_cols.append('_multiMatch')
asc.append(False)
if 'edhrecRank' in multi_pool.columns:
sort_cols.append('edhrecRank')
asc.append(True)
if 'manaValue' in multi_pool.columns:
sort_cols.append('manaValue')
asc.append(True)
multi_pool = multi_pool.sort_values(by=sort_cols, ascending=asc, na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
multi_pool = bu.prefer_owned_first(multi_pool, {str(n).lower() for n in owned_set})
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=bu.ensure_theme_tags_list(row.get('themeTags')),
role='creature',
sub_role='fill',
added_by='creature_fill',
synergy=int(round(row.get('_matchScore', row.get('_multiMatch', 0)))) if '_matchScore' in row else int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
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).")
# Summary output
self.output_func("\nCreatures Added:")
if all_theme_added:
self.output_func(f" All-Theme overlap: {len(all_theme_added)}")
for nm, hits in all_theme_added:
if hits:
self.output_func(f" - {nm} (tags: {', '.join(hits)})")
else:
self.output_func(f" - {nm}")
for target in themes_ordered:
role = target.role
tag = target.display
lst = per_theme_added.get(role, [])
if lst:
label = 'User' if target.source == 'user' else role.title()
self.output_func(f" {label} '{tag}': {len(lst)}")
for nm in lst:
self.output_func(f" - {nm}")
else:
label = 'User' if target.source == 'user' else role.title()
self.output_func(f" {label} '{tag}': 0")
self.output_func(f" Total {total_added}/{desired_total}{' (dataset shortfall)' if total_added < desired_total else ''}")
def add_creatures_phase(self):
"""Public method for orchestration: delegates to add_creatures.
Use this as the main entry point for the creature addition phase in deck building.
"""
"""Public method for orchestration: delegates to add_creatures."""
return self.add_creatures()
# ---------------------------
# Per-theme creature sub-stages (for web UI staged confirms)
# ---------------------------
def _theme_weights(self, themes_ordered: List[tuple[str, str]]) -> Dict[str, float]:
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
return weights
def _creature_count_in_library(self) -> int:
total = 0
try:
lib = getattr(self, 'card_library', {}) or {}
for name, entry in lib.items():
# Skip the commander from creature counts to preserve historical behavior
try:
if bool(entry.get('Commander')):
continue
except Exception:
pass
is_creature = False
# Prefer explicit Card Type recorded on the entry
try:
ctype = str(entry.get('Card Type') or '')
if ctype:
is_creature = ('creature' in ctype.lower())
except Exception:
is_creature = False
# Fallback: look up type from the combined dataframe snapshot
if not is_creature:
try:
df = getattr(self, '_combined_cards_df', None)
if df is not None and not getattr(df, 'empty', True) and 'name' in df.columns:
row = df[df['name'].astype(str).str.lower() == str(name).strip().lower()]
if not row.empty:
tline = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '')
if 'creature' in tline.lower():
is_creature = True
except Exception:
pass
if is_creature:
try:
total += int(entry.get('Count', 1))
except Exception:
total += 1
except Exception:
pass
return total
def _prepare_creature_pool(self):
df = getattr(self, '_combined_cards_df', None)
if df is None or df.empty or 'type' not in df.columns:
return None
creature_df = df[df['type'].str.contains('Creature', case=False, na=False)].copy()
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]
# Apply bracket-based pre-filters (e.g., disallow game changers or tutors when bracket limit == 0)
creature_df = self._apply_bracket_pre_filters(creature_df)
if creature_df.empty:
return None
if '_parsedThemeTags' not in creature_df.columns:
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
creature_df['_normTags'] = creature_df['_parsedThemeTags']
selected_tags_lower: List[str] = []
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if t:
selected_tags_lower.append(t.lower())
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
return creature_df
def _apply_bracket_pre_filters(self, df):
"""Preemptively filter disallowed categories for the current bracket for creatures.
Excludes when bracket limit == 0 for a category:
- Game Changers
- Nonland Tutors
Note: Extra Turns and Mass Land Denial generally don't apply to creature cards,
but if present as tags, they'll be respected too.
"""
try:
if df is None or getattr(df, 'empty', False):
return df
limits = getattr(self, 'bracket_limits', {}) or {}
disallow = {
'game_changers': (limits.get('game_changers') is not None and int(limits.get('game_changers')) == 0),
'tutors_nonland': (limits.get('tutors_nonland') is not None and int(limits.get('tutors_nonland')) == 0),
'extra_turns': (limits.get('extra_turns') is not None and int(limits.get('extra_turns')) == 0),
'mass_land_denial': (limits.get('mass_land_denial') is not None and int(limits.get('mass_land_denial')) == 0),
}
if not any(disallow.values()):
return df
def norm_tags(val):
try:
return [str(t).strip().lower() for t in (val or [])]
except Exception:
return []
if '_ltags' not in df.columns:
try:
if 'themeTags' in df.columns:
df = df.copy()
df['_ltags'] = df['themeTags'].apply(bu.normalize_tag_cell)
except Exception:
pass
tag_col = '_ltags' if '_ltags' in df.columns else ('themeTags' if 'themeTags' in df.columns else None)
if not tag_col:
return df
syn = {
'game_changers': { 'bracket:gamechanger', 'gamechanger', 'game-changer', 'game changer' },
'tutors_nonland': { 'bracket:tutornonland', 'tutor', 'tutors', 'nonland tutor', 'non-land tutor' },
'extra_turns': { 'bracket:extraturn', 'extra turn', 'extra turns', 'extraturn' },
'mass_land_denial': { 'bracket:masslanddenial', 'mass land denial', 'mld', 'masslanddenial' },
}
tags_series = df[tag_col].apply(norm_tags)
mask_keep = [True] * len(df)
for cat, dis in disallow.items():
if not dis:
continue
needles = syn.get(cat, set())
drop_idx = tags_series.apply(lambda lst, nd=needles: any(any(n in t for n in nd) for t in lst))
mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())]
try:
import pandas as _pd # type: ignore
mask_keep = _pd.Series(mask_keep, index=df.index)
except Exception:
pass
return df[mask_keep]
except Exception:
return df
def _add_creatures_for_role(self, role: str):
"""Add creatures for a single theme role ('primary'|'secondary'|'tertiary')."""
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
tag = getattr(self, f'{role}_tag', None)
if not tag:
return
themes_ordered: List[tuple[str, str]] = []
if getattr(self, 'primary_tag', None):
themes_ordered.append(('primary', self.primary_tag))
if getattr(self, 'secondary_tag', None):
themes_ordered.append(('secondary', self.secondary_tag))
if getattr(self, 'tertiary_tag', None):
themes_ordered.append(('tertiary', self.tertiary_tag))
weights = self._theme_weights(themes_ordered)
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
current_added = self._creature_count_in_library()
remaining = max(0, desired_total - current_added)
if remaining <= 0:
return
w = float(weights.get(role, 0.0))
if w <= 0:
return
import math as _math
target = int(_math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
target = min(target, remaining)
if target <= 0:
return
creature_df = self._prepare_creature_pool()
if creature_df is None:
self.output_func("No creature rows in dataset; skipping.")
return
tnorm = str(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.")
return
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')
base_top = 30
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
pool = subset.head(top_n).copy()
# Exclude any names already chosen
existing_names = set(getattr(self, 'card_library', {}).keys())
pool = pool[~pool['name'].isin(existing_names)]
if pool.empty:
return
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
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, rng=getattr(self, 'rng', None))
added = 0
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=bu.ensure_theme_tags_list(row.get('themeTags')),
role='creature',
sub_role=role,
added_by='creature_add',
trigger_tag=tag,
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
added += 1
if added >= target:
break
self.output_func(f"Added {added} creatures for {role} theme '{tag}' (target {target}).")
def _add_creatures_fill(self):
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
current_added = self._creature_count_in_library()
need = max(0, desired_total - current_added)
if need <= 0:
return
creature_df = self._prepare_creature_pool()
if creature_df is None:
return
multi_pool = creature_df[~creature_df['name'].isin(set(getattr(self, 'card_library', {}).keys()))].copy()
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if multi_pool.empty:
return
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]
added = 0
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=bu.ensure_theme_tags_list(row.get('themeTags')),
role='creature',
sub_role='fill',
added_by='creature_fill',
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
added += 1
if added >= need:
break
if added:
self.output_func(f"Fill pass added {added} extra creatures (shortfall compensation).")
# Public stage entry points (web orchestrator looks for these)
def add_creatures_primary_phase(self):
return self._add_creatures_for_role('primary')
def add_creatures_secondary_phase(self):
return self._add_creatures_for_role('secondary')
def add_creatures_tertiary_phase(self):
return self._add_creatures_for_role('tertiary')
def add_creatures_fill_phase(self):
return self._add_creatures_fill()
def add_creatures_all_theme_phase(self):
"""Staged pre-pass: when AND mode and 2+ tags, add creatures matching all selected themes first."""
combine_mode = getattr(self, 'tag_mode', 'AND')
tags = [t for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)] if t]
if combine_mode != 'AND' or len(tags) < 2:
return
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
current_added = self._creature_count_in_library()
remaining_capacity = max(0, desired_total - current_added)
if remaining_capacity <= 0:
return
creature_df = self._prepare_creature_pool()
if creature_df is None or creature_df.empty:
return
all_cnt = len(tags)
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
existing_names = set(getattr(self, 'card_library', {}).keys())
subset_all = subset_all[~subset_all['name'].isin(existing_names)]
if subset_all.empty or target_cap <= 0:
return
if 'edhrecRank' in subset_all.columns:
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset_all.columns:
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
weighted_pool = []
for nm in subset_all['name'].tolist():
w = weight_strong
if owned_lower and str(nm).lower() in owned_lower:
w *= owned_mult
weighted_pool.append((nm, w))
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap, rng=getattr(self, 'rng', None))
added = 0
for nm in chosen_all:
row = subset_all[subset_all['name'] == nm].iloc[0]
# Determine which selected themes this card hits for display
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
hits: List[str] = []
try:
hits = [t for t in tags if str(t).lower() in norm_tags]
except Exception:
hits = list(tags)
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=bu.ensure_theme_tags_list(row.get('themeTags')),
role='creature',
sub_role='all_theme',
added_by='creature_all_theme',
trigger_tag=", ".join(hits) if hits else None,
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
)
added += 1
if added >= target_cap:
break
if added:
self.output_func(f"All-Theme AND Pre-Pass: added {added}/{target_cap} creatures (matching all {all_cnt} themes)")