With assistance from Github CoPilot, massively overhauled the builder functionality, splitting it into smaller modules to provide a better step-by-step focus and drastically reduce the overall size of the core builder module

This commit is contained in:
mwisnowski 2025-08-20 10:46:23 -07:00
parent ff1912f979
commit 760c36d75d
17 changed files with 3044 additions and 2602 deletions

View file

@ -0,0 +1,613 @@
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()