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() 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 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)")