2025-08-20 10:46:23 -07:00
|
|
|
|
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'])
|
2025-08-26 16:25:34 -07:00
|
|
|
|
# Prefer-owned bias: stable reorder to put owned first while preserving prior sort
|
|
|
|
|
if getattr(self, 'prefer_owned', False):
|
|
|
|
|
owned_set = getattr(self, 'owned_card_names', None)
|
|
|
|
|
if owned_set:
|
|
|
|
|
owned_lower = {str(n).lower() for n in owned_set}
|
|
|
|
|
work = bu.prefer_owned_first(work, owned_lower)
|
2025-08-20 10:46:23 -07:00
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
# ---------------------------
|
2025-08-21 08:40:31 -07:00
|
|
|
|
def add_removal(self):
|
|
|
|
|
"""Add spot removal spells to the deck, avoiding board wipes and lands.
|
|
|
|
|
Selects cards tagged as 'removal' or 'spot removal', prioritizing by EDHREC rank and mana value.
|
|
|
|
|
Avoids duplicates and commander card.
|
|
|
|
|
"""
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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'])
|
2025-08-26 16:25:34 -07:00
|
|
|
|
if getattr(self, 'prefer_owned', False):
|
|
|
|
|
owned_set = getattr(self, 'owned_card_names', None)
|
|
|
|
|
if owned_set:
|
|
|
|
|
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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):
|
2025-08-21 08:40:31 -07:00
|
|
|
|
"""Add board wipe spells to the deck.
|
|
|
|
|
Selects cards tagged as 'board wipe' or 'mass removal', prioritizing by EDHREC rank and mana value.
|
|
|
|
|
Avoids duplicates and commander card.
|
|
|
|
|
"""
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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'])
|
2025-08-26 16:25:34 -07:00
|
|
|
|
if getattr(self, 'prefer_owned', False):
|
|
|
|
|
owned_set = getattr(self, 'owned_card_names', None)
|
|
|
|
|
if owned_set:
|
|
|
|
|
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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
|
|
|
|
|
# ---------------------------
|
2025-08-21 08:40:31 -07:00
|
|
|
|
def add_card_advantage(self):
|
|
|
|
|
"""Add card advantage spells to the deck.
|
|
|
|
|
Selects cards tagged as 'draw' or 'card advantage', splits between conditional and unconditional draw.
|
|
|
|
|
Prioritizes by EDHREC rank and mana value, avoids duplicates and commander card.
|
|
|
|
|
"""
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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)
|
2025-08-26 16:25:34 -07:00
|
|
|
|
if getattr(self, 'prefer_owned', False):
|
|
|
|
|
owned_set = getattr(self, 'owned_card_names', None)
|
|
|
|
|
if owned_set:
|
|
|
|
|
owned_lower = {str(n).lower() for n in owned_set}
|
|
|
|
|
conditional_df = bu.prefer_owned_first(conditional_df, owned_lower)
|
|
|
|
|
unconditional_df = bu.prefer_owned_first(unconditional_df, owned_lower)
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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):
|
2025-08-21 08:40:31 -07:00
|
|
|
|
"""Add protection spells to the deck.
|
|
|
|
|
Selects cards tagged as 'protection', prioritizing by EDHREC rank and mana value.
|
|
|
|
|
Avoids duplicates and commander card.
|
|
|
|
|
"""
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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'])
|
2025-08-26 16:25:34 -07:00
|
|
|
|
if getattr(self, 'prefer_owned', False):
|
|
|
|
|
owned_set = getattr(self, 'owned_card_names', None)
|
|
|
|
|
if owned_set:
|
|
|
|
|
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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
|
|
|
|
|
# ---------------------------
|
2025-08-21 08:40:31 -07:00
|
|
|
|
def fill_remaining_theme_spells(self):
|
|
|
|
|
"""Fill remaining deck slots with theme spells to reach 100 cards.
|
|
|
|
|
Uses primary, secondary, and tertiary tags to select spells matching deck themes.
|
|
|
|
|
Applies weighted selection and fallback to general utility spells if needed.
|
|
|
|
|
"""
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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)
|
|
|
|
|
)
|
2025-08-26 11:34:42 -07:00
|
|
|
|
combine_mode = getattr(self, 'tag_mode', 'AND')
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
]
|
2025-08-26 11:34:42 -07:00
|
|
|
|
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
|
|
|
|
if (spells_df['_multiMatch'] >= 2).any():
|
|
|
|
|
subset = subset[subset['_multiMatch'] >= 2]
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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',
|
|
|
|
|
)
|
2025-08-26 16:25:34 -07:00
|
|
|
|
# Prefer-owned: stable reorder before trimming to top_n
|
|
|
|
|
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})
|
2025-08-20 10:46:23 -07:00
|
|
|
|
pool = subset.head(top_n).copy()
|
|
|
|
|
pool = pool[~pool['name'].isin(self.card_library.keys())]
|
|
|
|
|
if pool.empty:
|
|
|
|
|
continue
|
2025-08-26 16:25:34 -07:00
|
|
|
|
# Build weighted pool with optional owned multiplier
|
|
|
|
|
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)
|
|
|
|
|
base_pairs = list(zip(pool['name'], pool['_multiMatch']))
|
|
|
|
|
weighted_pool: list[tuple[str, float]] = []
|
2025-08-26 11:34:42 -07:00
|
|
|
|
if combine_mode == 'AND':
|
2025-08-26 16:25:34 -07:00
|
|
|
|
for nm, mm in base_pairs:
|
|
|
|
|
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
|
|
|
|
|
if owned_lower and str(nm).lower() in owned_lower:
|
|
|
|
|
base_w *= owned_mult
|
|
|
|
|
weighted_pool.append((nm, base_w))
|
2025-08-26 11:34:42 -07:00
|
|
|
|
else:
|
2025-08-26 16:25:34 -07:00
|
|
|
|
for nm, mm in base_pairs:
|
|
|
|
|
base_w = (synergy_bonus if mm >= 2 else 1.0)
|
|
|
|
|
if owned_lower and str(nm).lower() in owned_lower:
|
|
|
|
|
base_w *= owned_mult
|
|
|
|
|
weighted_pool.append((nm, base_w))
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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()
|
2025-08-26 11:34:42 -07:00
|
|
|
|
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]
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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',
|
|
|
|
|
)
|
2025-08-26 16:25:34 -07:00
|
|
|
|
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})
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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')
|
2025-08-26 16:25:34 -07:00
|
|
|
|
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})
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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):
|
2025-08-21 08:40:31 -07:00
|
|
|
|
"""Orchestrate addition of all non-creature spell categories and theme filler.
|
|
|
|
|
Calls ramp, removal, board wipes, card advantage, protection, and theme filler methods in order.
|
|
|
|
|
"""
|
2025-08-20 10:46:23 -07:00
|
|
|
|
"""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()
|
2025-08-21 08:40:31 -07:00
|
|
|
|
|
|
|
|
|
def add_spells_phase(self):
|
|
|
|
|
"""Public method for orchestration: delegates to add_non_creature_spells.
|
|
|
|
|
Use this as the main entry point for the spell addition phase in deck building.
|
|
|
|
|
"""
|
|
|
|
|
"""Public method for orchestration: delegates to add_non_creature_spells."""
|
|
|
|
|
return self.add_non_creature_spells()
|
|
|
|
|
|