mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
- 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.
686 lines
36 KiB
Python
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)")
|