mtg_python_deckbuilder/code/deck_builder/phases/phase4_spells.py

613 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import math
from typing import List, Dict
from .. import builder_utils as bu
from .. import builder_constants as bc
import logging_util
logger = logging_util.logging.getLogger(__name__)
class SpellAdditionMixin:
"""Phase 4: Non-creature spell additions (ramp, removal, wipes, draw, protection, thematic filler).
Extracted intact from monolithic builder. Logic intentionally unchanged; future refinements
(e.g., further per-category sub-mixins) can split this class if complexity grows.
"""
# ---------------------------
# Ramp
# ---------------------------
def add_ramp(self): # noqa: C901
"""Add ramp pieces in three phases: mana rocks (~1/3), mana dorks (~1/4), then general/other.
Selection is deterministic priority based: lowest edhrecRank then lowest mana value.
No theme weighting simple best-available filtering while avoiding duplicates.
"""
if not self._combined_cards_df is not None: # preserve original logic
return
target_total = self.ideal_counts.get('ramp', 0)
if target_total <= 0:
return
already = {n.lower() for n in self.card_library.keys()}
df = self._combined_cards_df
if 'name' not in df.columns:
return
work = df.copy()
work['_ltags'] = work.get('themeTags', []).apply(bu.normalize_tag_cell)
work = work[work['_ltags'].apply(lambda tags: any('ramp' in t for t in tags))]
if work.empty:
self.output_func('No ramp-tagged cards found in dataset.')
return
existing_ramp = 0
for name, entry in self.card_library.items():
if any(isinstance(t, str) and 'ramp' in t.lower() for t in entry.get('Tags', [])):
existing_ramp += 1
to_add, _bonus = bu.compute_adjusted_target('Ramp', target_total, existing_ramp, self.output_func, plural_word='ramp spells')
if existing_ramp >= target_total and to_add == 0:
return
if existing_ramp < target_total:
target_total = to_add
else:
target_total = to_add
work = work[~work['type'].fillna('').str.contains('Land', case=False, na=False)]
commander_name = getattr(self, 'commander', None)
if commander_name:
work = work[work['name'] != commander_name]
work = bu.sort_by_priority(work, ['edhrecRank','manaValue'])
rocks_target = min(target_total, math.ceil(target_total/3))
dorks_target = min(target_total - rocks_target, math.ceil(target_total/4))
added_rocks: List[str] = []
added_dorks: List[str] = []
added_general: List[str] = []
def add_from_pool(pool, remaining_needed, added_list, phase_name):
added_now = 0
for _, r in pool.iterrows():
nm = r['name']
if nm.lower() in already:
continue
self.add_card(
nm,
card_type=r.get('type',''),
mana_cost=r.get('manaCost',''),
mana_value=r.get('manaValue', r.get('cmc','')),
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [],
role='ramp',
sub_role=phase_name.lower(),
added_by='spell_ramp'
)
already.add(nm.lower())
added_list.append(nm)
added_now += 1
if added_now >= remaining_needed:
break
if added_now:
self.output_func(f"Ramp phase {phase_name}: added {added_now}/{remaining_needed} target.")
return added_now
rocks_pool = work[work['type'].fillna('').str.contains('Artifact', case=False, na=False)]
if rocks_target > 0:
add_from_pool(rocks_pool, rocks_target, added_rocks, 'Rocks')
dorks_pool = work[work['type'].fillna('').str.contains('Creature', case=False, na=False)]
if dorks_target > 0:
add_from_pool(dorks_pool, dorks_target, added_dorks, 'Dorks')
current_total = len(added_rocks) + len(added_dorks)
remaining = target_total - current_total
if remaining > 0:
general_pool = work[~work['name'].isin(added_rocks + added_dorks)]
add_from_pool(general_pool, remaining, added_general, 'General')
total_added_now = len(added_rocks)+len(added_dorks)+len(added_general)
self.output_func(f"Total Ramp Added This Pass: {total_added_now}/{target_total}")
if total_added_now < target_total:
self.output_func('Ramp shortfall due to limited dataset.')
if total_added_now:
self.output_func("Ramp Cards Added:")
for nm in added_rocks:
self.output_func(f" [Rock] {nm}")
for nm in added_dorks:
self.output_func(f" [Dork] {nm}")
for nm in added_general:
self.output_func(f" [General] {nm}")
# ---------------------------
# Removal
# ---------------------------
def add_removal(self): # noqa: C901
target = self.ideal_counts.get('removal', 0)
if target <= 0 or self._combined_cards_df is None:
return
already = {n.lower() for n in self.card_library.keys()}
df = self._combined_cards_df.copy()
if 'name' not in df.columns:
return
df['_ltags'] = df.get('themeTags', []).apply(bu.normalize_tag_cell)
def is_removal(tags):
return any('removal' in t or 'spot removal' in t for t in tags)
def is_wipe(tags):
return any('board wipe' in t or 'mass removal' in t for t in tags)
pool = df[df['_ltags'].apply(is_removal) & ~df['_ltags'].apply(is_wipe)]
pool = pool[~pool['type'].fillna('').str.contains('Land', case=False, na=False)]
commander_name = getattr(self, 'commander', None)
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
existing = 0
for name, entry in self.card_library.items():
lt = [str(t).lower() for t in entry.get('Tags', [])]
if any(('removal' in t or 'spot removal' in t) for t in lt) and not any(('board wipe' in t or 'mass removal' in t) for t in lt):
existing += 1
to_add, _bonus = bu.compute_adjusted_target('Removal', target, existing, self.output_func, plural_word='removal spells')
if existing >= target and to_add == 0:
return
target = to_add if existing < target else to_add
added = 0
added_names: List[str] = []
for _, r in pool.iterrows():
if added >= target:
break
nm = r['name']
if nm.lower() in already:
continue
self.add_card(
nm,
card_type=r.get('type',''),
mana_cost=r.get('manaCost',''),
mana_value=r.get('manaValue', r.get('cmc','')),
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [],
role='removal',
sub_role='spot',
added_by='spell_removal'
)
already.add(nm.lower())
added += 1
added_names.append(nm)
self.output_func(f"Added Spot Removal This Pass: {added}/{target}{' (dataset shortfall)' if added < target else ''}")
if added_names:
self.output_func('Removal Cards Added:')
for nm in added_names:
self.output_func(f" - {nm}")
# ---------------------------
# Board Wipes
# ---------------------------
def add_board_wipes(self):
target = self.ideal_counts.get('wipes', 0)
if target <= 0 or self._combined_cards_df is None:
return
already = {n.lower() for n in self.card_library.keys()}
df = self._combined_cards_df.copy()
df['_ltags'] = df.get('themeTags', []).apply(bu.normalize_tag_cell)
def is_wipe(tags):
return any('board wipe' in t or 'mass removal' in t for t in tags)
pool = df[df['_ltags'].apply(is_wipe)]
pool = pool[~pool['type'].fillna('').str.contains('Land', case=False, na=False)]
commander_name = getattr(self, 'commander', None)
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
existing = 0
for name, entry in self.card_library.items():
tags = [str(t).lower() for t in entry.get('Tags', [])]
if any(('board wipe' in t or 'mass removal' in t) for t in tags):
existing += 1
to_add, _bonus = bu.compute_adjusted_target('Board wipe', target, existing, self.output_func, plural_word='wipes')
if existing >= target and to_add == 0:
return
target = to_add if existing < target else to_add
added = 0
added_names: List[str] = []
for _, r in pool.iterrows():
if added >= target:
break
nm = r['name']
if nm.lower() in already:
continue
self.add_card(
nm,
card_type=r.get('type',''),
mana_cost=r.get('manaCost',''),
mana_value=r.get('manaValue', r.get('cmc','')),
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [],
role='wipe',
sub_role='board',
added_by='spell_wipe'
)
already.add(nm.lower())
added += 1
added_names.append(nm)
self.output_func(f"Added Board Wipes This Pass: {added}/{target}{' (dataset shortfall)' if added < target else ''}")
if added_names:
self.output_func('Board Wipes Added:')
for nm in added_names:
self.output_func(f" - {nm}")
# ---------------------------
# Card Advantage
# ---------------------------
def add_card_advantage(self): # noqa: C901
total_target = self.ideal_counts.get('card_advantage', 0)
if total_target <= 0 or self._combined_cards_df is None:
return
existing = 0
for name, entry in self.card_library.items():
tags = [str(t).lower() for t in entry.get('Tags', [])]
if any(('draw' in t) or ('card advantage' in t) for t in tags):
existing += 1
to_add_total, _bonus = bu.compute_adjusted_target('Card advantage', total_target, existing, self.output_func, plural_word='draw spells')
if existing >= total_target and to_add_total == 0:
return
total_target = to_add_total if existing < total_target else to_add_total
conditional_target = min(total_target, math.ceil(total_target * 0.2))
already = {n.lower() for n in self.card_library.keys()}
df = self._combined_cards_df.copy()
df['_ltags'] = df.get('themeTags', []).apply(bu.normalize_tag_cell)
def is_draw(tags):
return any(('draw' in t) or ('card advantage' in t) for t in tags)
df = df[df['_ltags'].apply(is_draw)]
df = df[~df['type'].fillna('').str.contains('Land', case=False, na=False)]
commander_name = getattr(self, 'commander', None)
if commander_name:
df = df[df['name'] != commander_name]
CONDITIONAL_KEYS = ['conditional', 'situational', 'attacks', 'combat damage', 'when you cast']
def is_conditional(tags):
return any(any(k in t for k in CONDITIONAL_KEYS) for t in tags)
conditional_df = df[df['_ltags'].apply(is_conditional)]
unconditional_df = df[~df.index.isin(conditional_df.index)]
def sortit(d):
return bu.sort_by_priority(d, ['edhrecRank','manaValue'])
conditional_df = sortit(conditional_df)
unconditional_df = sortit(unconditional_df)
added_cond = 0
added_cond_names: List[str] = []
for _, r in conditional_df.iterrows():
if added_cond >= conditional_target:
break
nm = r['name']
if nm.lower() in already:
continue
self.add_card(
nm,
card_type=r.get('type',''),
mana_cost=r.get('manaCost',''),
mana_value=r.get('manaValue', r.get('cmc','')),
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [],
role='card_advantage',
sub_role='conditional',
added_by='spell_draw'
)
already.add(nm.lower())
added_cond += 1
added_cond_names.append(nm)
remaining = total_target - added_cond
added_uncond = 0
added_uncond_names: List[str] = []
if remaining > 0:
for _, r in unconditional_df.iterrows():
if added_uncond >= remaining:
break
nm = r['name']
if nm.lower() in already:
continue
self.add_card(
nm,
card_type=r.get('type',''),
mana_cost=r.get('manaCost',''),
mana_value=r.get('manaValue', r.get('cmc','')),
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [],
role='card_advantage',
sub_role='unconditional',
added_by='spell_draw'
)
already.add(nm.lower())
added_uncond += 1
added_uncond_names.append(nm)
self.output_func(f"Added Card Advantage This Pass: conditional {added_cond}/{conditional_target}, total {(added_cond+added_uncond)}/{total_target}{' (dataset shortfall)' if (added_cond+added_uncond) < total_target else ''}")
if added_cond_names or added_uncond_names:
self.output_func('Card Advantage Cards Added:')
for nm in added_cond_names:
self.output_func(f" [Conditional] {nm}")
for nm in added_uncond_names:
self.output_func(f" [Unconditional] {nm}")
# ---------------------------
# Protection
# ---------------------------
def add_protection(self):
target = self.ideal_counts.get('protection', 0)
if target <= 0 or self._combined_cards_df is None:
return
already = {n.lower() for n in self.card_library.keys()}
df = self._combined_cards_df.copy()
df['_ltags'] = df.get('themeTags', []).apply(bu.normalize_tag_cell)
pool = df[df['_ltags'].apply(lambda tags: any('protection' in t for t in tags))]
pool = pool[~pool['type'].fillna('').str.contains('Land', case=False, na=False)]
commander_name = getattr(self, 'commander', None)
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
existing = 0
for name, entry in self.card_library.items():
tags = [str(t).lower() for t in entry.get('Tags', [])]
if any('protection' in t for t in tags):
existing += 1
to_add, _bonus = bu.compute_adjusted_target('Protection', target, existing, self.output_func, plural_word='protection spells')
if existing >= target and to_add == 0:
return
target = to_add if existing < target else to_add
added = 0
added_names: List[str] = []
for _, r in pool.iterrows():
if added >= target:
break
nm = r['name']
if nm.lower() in already:
continue
self.add_card(
nm,
card_type=r.get('type',''),
mana_cost=r.get('manaCost',''),
mana_value=r.get('manaValue', r.get('cmc','')),
tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [],
role='protection',
added_by='spell_protection'
)
already.add(nm.lower())
added += 1
added_names.append(nm)
self.output_func(f"Added Protection This Pass: {added}/{target}{' (dataset shortfall)' if added < target else ''}")
if added_names:
self.output_func('Protection Cards Added:')
for nm in added_names:
self.output_func(f" - {nm}")
# ---------------------------
# Theme Spell Filler to 100
# ---------------------------
def fill_remaining_theme_spells(self): # noqa: C901
total_cards = sum(entry.get('Count', 1) for entry in self.card_library.values())
remaining = 100 - total_cards
if remaining <= 0:
return
df = getattr(self, '_combined_cards_df', None)
if df is None or df.empty or 'type' not in df.columns:
return
themes_ordered: List[tuple[str, str]] = []
if self.primary_tag:
themes_ordered.append(('primary', self.primary_tag))
if self.secondary_tag:
themes_ordered.append(('secondary', self.secondary_tag))
if self.tertiary_tag:
themes_ordered.append(('tertiary', self.tertiary_tag))
if not themes_ordered:
return
n_themes = len(themes_ordered)
if n_themes == 1:
base_map = {'primary': 1.0}
elif n_themes == 2:
base_map = {'primary': 0.6, 'secondary': 0.4}
else:
base_map = {'primary': 0.5, 'secondary': 0.3, 'tertiary': 0.2}
weights: Dict[str, float] = {}
boosted: set[str] = set()
if n_themes > 1:
for role, tag in themes_ordered:
w = base_map.get(role, 0.0)
lt = tag.lower()
if 'kindred' in lt or 'tribal' in lt:
mult = getattr(bc, 'WEIGHT_ADJUSTMENT_FACTORS', {}).get(f'kindred_{role}', 1.0)
w *= mult
boosted.add(role)
weights[role] = w
tot = sum(weights.values())
if tot > 1.0:
for r in weights:
weights[r] /= tot
else:
rem = 1.0 - tot
base_sum_unboosted = sum(base_map[r] for r, _ in themes_ordered if r not in boosted)
if rem > 1e-6 and base_sum_unboosted > 0:
for r, _ in themes_ordered:
if r not in boosted:
weights[r] += rem * (base_map[r] / base_sum_unboosted)
else:
weights['primary'] = 1.0
spells_df = df[
~df['type'].str.contains('Land', case=False, na=False)
& ~df['type'].str.contains('Creature', case=False, na=False)
].copy()
if spells_df.empty:
return
selected_tags_lower = [t.lower() for _r, t in themes_ordered]
if '_parsedThemeTags' not in spells_df.columns:
spells_df['_parsedThemeTags'] = spells_df['themeTags'].apply(bu.normalize_tag_cell)
spells_df['_normTags'] = spells_df['_parsedThemeTags']
spells_df['_multiMatch'] = spells_df['_normTags'].apply(
lambda lst: sum(1 for t in selected_tags_lower if t in lst)
)
base_top = 40
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
per_theme_added: Dict[str, List[str]] = {r: [] for r, _t in themes_ordered}
total_added = 0
for role, tag in themes_ordered:
if remaining - total_added <= 0:
break
w = weights.get(role, 0.0)
if w <= 0:
continue
target = int(math.ceil(remaining * w * self._get_rng().uniform(1.0, 1.1)))
target = min(target, remaining - total_added)
if target <= 0:
continue
tnorm = tag.lower()
subset = spells_df[
spells_df['_normTags'].apply(
lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst)
)
]
if subset.empty:
continue
if 'edhrecRank' in subset.columns:
subset = subset.sort_values(
by=['_multiMatch', 'edhrecRank', 'manaValue'],
ascending=[False, True, True],
na_position='last',
)
elif 'manaValue' in subset.columns:
subset = subset.sort_values(
by=['_multiMatch', 'manaValue'],
ascending=[False, True],
na_position='last',
)
pool = subset.head(top_n).copy()
pool = pool[~pool['name'].isin(self.card_library.keys())]
if pool.empty:
continue
weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
for nm in chosen:
row = pool[pool['name'] == nm].iloc[0]
self.add_card(
nm,
card_type=row.get('type', ''),
mana_cost=row.get('manaCost', ''),
mana_value=row.get('manaValue', row.get('cmc', '')),
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
role='theme_spell',
sub_role=role,
added_by='spell_theme_fill',
trigger_tag=tag,
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
per_theme_added[role].append(nm)
total_added += 1
if total_added >= remaining:
break
if total_added < remaining:
need = remaining - total_added
multi_pool = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy()
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if not multi_pool.empty:
if 'edhrecRank' in multi_pool.columns:
multi_pool = multi_pool.sort_values(
by=['_multiMatch', 'edhrecRank', 'manaValue'],
ascending=[False, True, True],
na_position='last',
)
elif 'manaValue' in multi_pool.columns:
multi_pool = multi_pool.sort_values(
by=['_multiMatch', 'manaValue'],
ascending=[False, True],
na_position='last',
)
fill = multi_pool['name'].tolist()[:need]
for nm in fill:
row = multi_pool[multi_pool['name'] == nm].iloc[0]
self.add_card(
nm,
card_type=row.get('type', ''),
mana_cost=row.get('manaCost', ''),
mana_value=row.get('manaValue', row.get('cmc', '')),
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
role='theme_spell',
sub_role='fill_multi',
added_by='spell_theme_fill',
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
total_added += 1
if total_added >= remaining:
break
if total_added < remaining:
extra_needed = remaining - total_added
leftover = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy()
if not leftover.empty:
if '_normTags' not in leftover.columns:
leftover['_normTags'] = leftover['themeTags'].apply(
lambda x: [str(t).lower() for t in x] if isinstance(x, list) else []
)
def has_any(tag_list, needles):
return any(any(nd in t for nd in needles) for t in tag_list)
def classify(row):
tags = row['_normTags']
if has_any(tags, ['ramp']):
return 'ramp'
if has_any(tags, ['card advantage', 'draw']):
return 'card_advantage'
if has_any(tags, ['protection']):
return 'protection'
if has_any(tags, ['board wipe', 'mass removal']):
return 'board_wipe'
if has_any(tags, ['removal']):
return 'removal'
return ''
leftover['_fillerCat'] = leftover.apply(classify, axis=1)
random_added: List[str] = []
for _ in range(extra_needed):
candidates_by_cat: Dict[str, any] = {}
for cat in ['ramp','card_advantage','protection','board_wipe','removal']:
subset = leftover[leftover['_fillerCat'] == cat]
if not subset.empty:
candidates_by_cat[cat] = subset
if not candidates_by_cat:
subset = leftover
else:
cat_choice = self._get_rng().choice(list(candidates_by_cat.keys()))
subset = candidates_by_cat[cat_choice]
if 'edhrecRank' in subset.columns:
subset = subset.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset.columns:
subset = subset.sort_values(by=['manaValue'], ascending=[True], na_position='last')
row = subset.head(1)
if row.empty:
break
r0 = row.iloc[0]
nm = r0['name']
self.add_card(
nm,
card_type=r0.get('type',''),
mana_cost=r0.get('manaCost',''),
mana_value=r0.get('manaValue', r0.get('cmc','')),
tags=r0.get('themeTags', []) if isinstance(r0.get('themeTags', []), list) else [],
role='filler',
sub_role=r0.get('_fillerCat',''),
added_by='spell_general_filler'
)
random_added.append(nm)
leftover = leftover[leftover['name'] != nm]
total_added += 1
if total_added >= remaining:
break
if random_added:
self.output_func(" General Utility Filler Added:")
for nm in random_added:
self.output_func(f" - {nm}")
if total_added:
self.output_func("\nFinal Theme Spell Fill:")
for role, tag in themes_ordered:
lst = per_theme_added.get(role, [])
if lst:
self.output_func(f" {role.title()} '{tag}': {len(lst)}")
for nm in lst:
self.output_func(f" - {nm}")
self.output_func(f" Total Theme Spells Added: {total_added}")
# ---------------------------
# Orchestrator
# ---------------------------
def add_non_creature_spells(self):
"""Convenience orchestrator calling remaining non-creature spell categories then thematic fill."""
self.add_ramp()
self.add_removal()
self.add_board_wipes()
self.add_card_advantage()
self.add_protection()
self.fill_remaining_theme_spells()
self.print_type_summary()