mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-21 10:00:12 +01:00
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
This commit is contained in:
parent
8fa040a05a
commit
0f73a85a4e
43 changed files with 4515 additions and 105 deletions
|
|
@ -24,11 +24,56 @@ class CommanderSelectionMixin:
|
|||
# ---------------------------
|
||||
# Commander Selection
|
||||
# ---------------------------
|
||||
def _normalize_commander_query(self, s: str) -> str:
|
||||
"""Return a nicely capitalized search string (e.g., "inti, seneschal of the sun"
|
||||
-> "Inti, Seneschal of the Sun"). Keeps small words lowercase unless at a segment start,
|
||||
and capitalizes parts around hyphens/apostrophes.
|
||||
"""
|
||||
if not isinstance(s, str):
|
||||
return str(s)
|
||||
s = s.strip()
|
||||
if not s:
|
||||
return s
|
||||
small = {
|
||||
'a','an','and','as','at','but','by','for','in','of','on','or','the','to','vs','v','with','from','into','over','per'
|
||||
}
|
||||
# Consider a new segment after these punctuation marks
|
||||
segment_breakers = {':',';','-','–','—','/','\\','(', '[', '{', '"', "'", ',', '.'}
|
||||
out_words: list[str] = []
|
||||
start_of_segment = True
|
||||
for raw in s.lower().split():
|
||||
word = raw
|
||||
# If preceding token ended with a breaker, reset segment
|
||||
if out_words:
|
||||
prev = out_words[-1]
|
||||
if prev and prev[-1] in segment_breakers:
|
||||
start_of_segment = True
|
||||
def cap_subparts(token: str) -> str:
|
||||
# Capitalize around hyphens and apostrophes
|
||||
def cap_piece(piece: str) -> str:
|
||||
return piece[:1].upper() + piece[1:] if piece else piece
|
||||
parts = [cap_piece(p) for p in token.split("'")]
|
||||
token2 = "'".join(parts)
|
||||
parts2 = [cap_piece(p) for p in token2.split('-')]
|
||||
return '-'.join(parts2)
|
||||
if start_of_segment or word not in small:
|
||||
fixed = cap_subparts(word)
|
||||
else:
|
||||
fixed = word
|
||||
out_words.append(fixed)
|
||||
# Next word is not start unless current ends with breaker
|
||||
start_of_segment = word[-1:] in segment_breakers
|
||||
# Post-process to ensure first character is capitalized if needed
|
||||
if out_words:
|
||||
out_words[0] = out_words[0][:1].upper() + out_words[0][1:]
|
||||
return ' '.join(out_words)
|
||||
|
||||
def choose_commander(self) -> str: # type: ignore[override]
|
||||
df = self.load_commander_data()
|
||||
names = df["name"].tolist()
|
||||
while True:
|
||||
query = self.input_func("Enter commander name: ").strip()
|
||||
query = self._normalize_commander_query(query)
|
||||
if not query:
|
||||
self.output_func("No input provided. Try again.")
|
||||
continue
|
||||
|
|
@ -66,7 +111,7 @@ class CommanderSelectionMixin:
|
|||
else:
|
||||
self.output_func("Invalid index.")
|
||||
continue
|
||||
query = choice # treat as new query
|
||||
query = self._normalize_commander_query(choice) # treat as new (normalized) query
|
||||
|
||||
def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override]
|
||||
row = df[df["name"] == name].iloc[0]
|
||||
|
|
|
|||
|
|
@ -144,8 +144,17 @@ class LandFetchMixin:
|
|||
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined]
|
||||
|
||||
def run_land_step4(self, requested_count: int | None = None): # type: ignore[override]
|
||||
"""Public wrapper to add fetch lands. Optional requested_count to bypass prompt."""
|
||||
self.add_fetch_lands(requested_count=requested_count)
|
||||
"""Public wrapper to add fetch lands.
|
||||
|
||||
If ideal_counts['fetch_lands'] is set, it will be used to bypass the prompt in both CLI and web builds.
|
||||
"""
|
||||
desired = requested_count
|
||||
try:
|
||||
if desired is None and getattr(self, 'ideal_counts', None) and 'fetch_lands' in self.ideal_counts:
|
||||
desired = int(self.ideal_counts['fetch_lands'])
|
||||
except Exception:
|
||||
desired = requested_count
|
||||
self.add_fetch_lands(requested_count=desired)
|
||||
self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined]
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
|
|
@ -190,3 +190,197 @@ class CreatureAdditionMixin:
|
|||
"""
|
||||
"""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:
|
||||
for _n, entry in getattr(self, 'card_library', {}).items():
|
||||
if str(entry.get('Role') or '').strip() == 'creature':
|
||||
total += int(entry.get('Count', 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]
|
||||
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 _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)
|
||||
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=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||
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=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class ColorBalanceMixin:
|
|||
self,
|
||||
pip_weights: Optional[Dict[str, float]] = None,
|
||||
color_shortfall_threshold: float = 0.15,
|
||||
perform_swaps: bool = True,
|
||||
perform_swaps: bool = False,
|
||||
max_swaps: int = 5,
|
||||
rebalance_basics: bool = True
|
||||
):
|
||||
|
|
@ -93,54 +93,56 @@ class ColorBalanceMixin:
|
|||
self.output_func(" Deficits (need more sources):")
|
||||
for c, pip_share, s_share, gap in deficits:
|
||||
self.output_func(f" {c}: need +{gap*100:.1f}% sources (pip {pip_share*100:.1f}% vs sources {s_share*100:.1f}%)")
|
||||
if not perform_swaps or not deficits:
|
||||
# We'll conditionally perform swaps; but even when skipping swaps we continue to basic rebalance.
|
||||
do_swaps = bool(perform_swaps and deficits)
|
||||
if not do_swaps:
|
||||
self.output_func(" (No land swaps performed.)")
|
||||
return
|
||||
|
||||
df = getattr(self, '_combined_cards_df', None)
|
||||
if df is None or df.empty:
|
||||
self.output_func(" Swap engine: card pool unavailable; aborting swaps.")
|
||||
return
|
||||
deficits.sort(key=lambda x: x[3], reverse=True)
|
||||
swaps_done: List[tuple[str,str,str]] = []
|
||||
overages: Dict[str,float] = {}
|
||||
for c in ['W','U','B','R','G']:
|
||||
over = source_share.get(c,0.0) - pip_weights.get(c,0.0)
|
||||
if over > 0:
|
||||
overages[c] = over
|
||||
if do_swaps:
|
||||
df = getattr(self, '_combined_cards_df', None)
|
||||
if df is None or df.empty:
|
||||
self.output_func(" Swap engine: card pool unavailable; aborting swaps.")
|
||||
else:
|
||||
deficits.sort(key=lambda x: x[3], reverse=True)
|
||||
overages: Dict[str,float] = {}
|
||||
for c in ['W','U','B','R','G']:
|
||||
over = source_share.get(c,0.0) - pip_weights.get(c,0.0)
|
||||
if over > 0:
|
||||
overages[c] = over
|
||||
|
||||
def removal_candidate(exclude_colors: set[str]) -> Optional[str]:
|
||||
return bu.select_color_balance_removal(self, exclude_colors, overages)
|
||||
def removal_candidate(exclude_colors: set[str]) -> Optional[str]:
|
||||
return bu.select_color_balance_removal(self, exclude_colors, overages)
|
||||
|
||||
def addition_candidates(target_color: str) -> List[str]:
|
||||
return bu.color_balance_addition_candidates(self, target_color, df)
|
||||
def addition_candidates(target_color: str) -> List[str]:
|
||||
return bu.color_balance_addition_candidates(self, target_color, df)
|
||||
|
||||
for color, _, _, gap in deficits:
|
||||
if len(swaps_done) >= max_swaps:
|
||||
break
|
||||
adds = addition_candidates(color)
|
||||
if not adds:
|
||||
continue
|
||||
to_add = None
|
||||
for cand in adds:
|
||||
if cand not in self.card_library:
|
||||
to_add = cand
|
||||
break
|
||||
if not to_add:
|
||||
continue
|
||||
to_remove = removal_candidate({color})
|
||||
if not to_remove:
|
||||
continue
|
||||
if not self._decrement_card(to_remove):
|
||||
continue
|
||||
self.add_card(to_add, card_type='Land', role='color-fix', sub_role='swap-add', added_by='color_balance')
|
||||
swaps_done.append((to_remove, to_add, color))
|
||||
current_counts = self._current_color_source_counts()
|
||||
total_sources = sum(current_counts.values()) or 1
|
||||
source_share = {c: current_counts[c]/total_sources for c in current_counts}
|
||||
new_gap = pip_weights.get(color,0.0) - source_share.get(color,0.0)
|
||||
if new_gap <= color_shortfall_threshold:
|
||||
continue
|
||||
for color, _, _, gap in deficits:
|
||||
if len(swaps_done) >= max_swaps:
|
||||
break
|
||||
adds = addition_candidates(color)
|
||||
if not adds:
|
||||
continue
|
||||
to_add = None
|
||||
for cand in adds:
|
||||
if cand not in self.card_library:
|
||||
to_add = cand
|
||||
break
|
||||
if not to_add:
|
||||
continue
|
||||
to_remove = removal_candidate({color})
|
||||
if not to_remove:
|
||||
continue
|
||||
if not self._decrement_card(to_remove):
|
||||
continue
|
||||
self.add_card(to_add, card_type='Land', role='color-fix', sub_role='swap-add', added_by='color_balance')
|
||||
swaps_done.append((to_remove, to_add, color))
|
||||
current_counts = self._current_color_source_counts()
|
||||
total_sources = sum(current_counts.values()) or 1
|
||||
source_share = {c: current_counts[c]/total_sources for c in current_counts}
|
||||
new_gap = pip_weights.get(color,0.0) - source_share.get(color,0.0)
|
||||
if new_gap <= color_shortfall_threshold:
|
||||
continue
|
||||
|
||||
if swaps_done:
|
||||
self.output_func("\nColor Balance Swaps Performed:")
|
||||
|
|
@ -152,52 +154,54 @@ class ColorBalanceMixin:
|
|||
self.output_func(" Updated Source Shares:")
|
||||
for c in ['W','U','B','R','G']:
|
||||
self.output_func(f" {c}: {final_source_share.get(c,0.0)*100:5.1f}% (pip {pip_weights.get(c,0.0)*100:5.1f}%)")
|
||||
if rebalance_basics:
|
||||
try:
|
||||
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
|
||||
basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()}
|
||||
if basics_present:
|
||||
total_basics = sum(e.get('Count',1) for e in basics_present.values())
|
||||
if total_basics > 0:
|
||||
desired_per_color: Dict[str,int] = {}
|
||||
for c, basic_name in basic_map.items():
|
||||
if c not in ['W','U','B','R','G']:
|
||||
continue
|
||||
desired = pip_weights.get(c,0.0) * total_basics
|
||||
desired_per_color[c] = int(round(desired))
|
||||
drift = total_basics - sum(desired_per_color.values())
|
||||
if drift != 0:
|
||||
ordered = sorted(desired_per_color.items(), key=lambda kv: pip_weights.get(kv[0],0.0), reverse=(drift>0))
|
||||
i = 0
|
||||
while drift != 0 and ordered:
|
||||
c,_ = ordered[i % len(ordered)]
|
||||
desired_per_color[c] += 1 if drift>0 else -1
|
||||
drift += -1 if drift>0 else 1
|
||||
i += 1
|
||||
changes: List[tuple[str,int,int]] = []
|
||||
for c, basic_name in basic_map.items():
|
||||
if c not in ['W','U','B','R','G']:
|
||||
continue
|
||||
target = max(0, desired_per_color.get(c,0))
|
||||
entry = self.card_library.get(basic_name)
|
||||
old = entry.get('Count',0) if entry else 0
|
||||
if old == 0 and target>0:
|
||||
for _ in range(target):
|
||||
self.add_card(basic_name, card_type='Land')
|
||||
changes.append((basic_name, 0, target))
|
||||
elif entry and old != target:
|
||||
if target > old:
|
||||
for _ in range(target-old):
|
||||
self.add_card(basic_name, card_type='Land')
|
||||
else:
|
||||
for _ in range(old-target):
|
||||
self._decrement_card(basic_name)
|
||||
changes.append((basic_name, old, target))
|
||||
if changes:
|
||||
self.output_func("\nBasic Land Rebalance (toward pip distribution):")
|
||||
for nm, old, new in changes:
|
||||
self.output_func(f" {nm}: {old} -> {new}")
|
||||
except Exception as e: # pragma: no cover (defensive)
|
||||
self.output_func(f" Basic rebalance skipped (error: {e})")
|
||||
else:
|
||||
elif do_swaps:
|
||||
self.output_func(" (No viable swaps executed.)")
|
||||
|
||||
# Always consider basic-land rebalance when requested
|
||||
if rebalance_basics:
|
||||
try:
|
||||
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
|
||||
basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()}
|
||||
if basics_present:
|
||||
total_basics = sum(e.get('Count',1) for e in basics_present.values())
|
||||
if total_basics > 0:
|
||||
desired_per_color: Dict[str,int] = {}
|
||||
for c, basic_name in basic_map.items():
|
||||
if c not in ['W','U','B','R','G']:
|
||||
continue
|
||||
desired = pip_weights.get(c,0.0) * total_basics
|
||||
desired_per_color[c] = int(round(desired))
|
||||
drift = total_basics - sum(desired_per_color.values())
|
||||
if drift != 0:
|
||||
ordered = sorted(desired_per_color.items(), key=lambda kv: pip_weights.get(kv[0],0.0), reverse=(drift>0))
|
||||
i = 0
|
||||
while drift != 0 and ordered:
|
||||
c,_ = ordered[i % len(ordered)]
|
||||
desired_per_color[c] += 1 if drift>0 else -1
|
||||
drift += -1 if drift>0 else 1
|
||||
i += 1
|
||||
changes: List[tuple[str,int,int]] = []
|
||||
for c, basic_name in basic_map.items():
|
||||
if c not in ['W','U','B','R','G']:
|
||||
continue
|
||||
target = max(0, desired_per_color.get(c,0))
|
||||
entry = self.card_library.get(basic_name)
|
||||
old = entry.get('Count',0) if entry else 0
|
||||
if old == 0 and target>0:
|
||||
for _ in range(target):
|
||||
self.add_card(basic_name, card_type='Land')
|
||||
changes.append((basic_name, 0, target))
|
||||
elif entry and old != target:
|
||||
if target > old:
|
||||
for _ in range(target-old):
|
||||
self.add_card(basic_name, card_type='Land')
|
||||
else:
|
||||
for _ in range(old-target):
|
||||
self._decrement_card(basic_name)
|
||||
changes.append((basic_name, old, target))
|
||||
if changes:
|
||||
self.output_func("\nBasic Land Rebalance (toward pip distribution):")
|
||||
for nm, old, new in changes:
|
||||
self.output_func(f" {nm}: {old} -> {new}")
|
||||
except Exception as e: # pragma: no cover (defensive)
|
||||
self.output_func(f" Basic rebalance skipped (error: {e})")
|
||||
|
|
|
|||
|
|
@ -108,6 +108,192 @@ class ReportingMixin:
|
|||
for cat, c in sorted(cat_counts.items(), key=lambda kv: (precedence_index.get(kv[0], 999), -kv[1], kv[0])):
|
||||
pct = (c / total_cards * 100) if total_cards else 0.0
|
||||
self.output_func(f" {cat:<15} {c:>3} ({pct:5.1f}%)")
|
||||
|
||||
# ---------------------------
|
||||
# Structured deck summary for UI (types, pips, sources, curve)
|
||||
# ---------------------------
|
||||
def build_deck_summary(self) -> dict:
|
||||
"""Return a structured summary of the finished deck for UI rendering.
|
||||
|
||||
Structure:
|
||||
{
|
||||
'type_breakdown': {
|
||||
'counts': { type: count, ... },
|
||||
'order': [sorted types by precedence],
|
||||
'cards': { type: [ {name, count}, ... ] },
|
||||
'total': int
|
||||
},
|
||||
'pip_distribution': {
|
||||
'counts': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n },
|
||||
'weights': { 'W': 0-1, ... }, # normalized weights (may not sum exactly to 1 due to rounding)
|
||||
},
|
||||
'mana_generation': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n, 'total_sources': n },
|
||||
'mana_curve': { '0': n, '1': n, '2': n, '3': n, '4': n, '5': n, '6+': n, 'total_spells': n }
|
||||
}
|
||||
"""
|
||||
# Build lookup to enrich type and mana values
|
||||
full_df = getattr(self, '_full_cards_df', None)
|
||||
combined_df = getattr(self, '_combined_cards_df', None)
|
||||
snapshot = full_df if full_df is not None else combined_df
|
||||
row_lookup: Dict[str, any] = {}
|
||||
if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns:
|
||||
for _, r in snapshot.iterrows(): # type: ignore[attr-defined]
|
||||
nm = str(r.get('name'))
|
||||
if nm and nm not in row_lookup:
|
||||
row_lookup[nm] = r
|
||||
|
||||
# Category classification (reuse export logic)
|
||||
precedence_order = [
|
||||
'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other'
|
||||
]
|
||||
precedence_index = {k: i for i, k in enumerate(precedence_order)}
|
||||
commander_name = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
|
||||
def classify(primary_type_line: str, card_name: str) -> str:
|
||||
if commander_name and card_name == commander_name:
|
||||
return 'Commander'
|
||||
tl = (primary_type_line or '').lower()
|
||||
if 'battle' in tl:
|
||||
return 'Battle'
|
||||
if 'planeswalker' in tl:
|
||||
return 'Planeswalker'
|
||||
if 'creature' in tl:
|
||||
return 'Creature'
|
||||
if 'instant' in tl:
|
||||
return 'Instant'
|
||||
if 'sorcery' in tl:
|
||||
return 'Sorcery'
|
||||
if 'artifact' in tl:
|
||||
return 'Artifact'
|
||||
if 'enchantment' in tl:
|
||||
return 'Enchantment'
|
||||
if 'land' in tl:
|
||||
return 'Land'
|
||||
return 'Other'
|
||||
|
||||
# Type breakdown (counts and per-type card lists)
|
||||
type_counts: Dict[str, int] = {}
|
||||
type_cards: Dict[str, list] = {}
|
||||
total_cards = 0
|
||||
for name, info in self.card_library.items():
|
||||
# Exclude commander from type breakdown per UI preference
|
||||
if commander_name and name == commander_name:
|
||||
continue
|
||||
cnt = int(info.get('Count', 1))
|
||||
base_type = info.get('Card Type') or info.get('Type', '')
|
||||
if not base_type:
|
||||
row = row_lookup.get(name)
|
||||
if row is not None:
|
||||
base_type = row.get('type', row.get('type_line', '')) or ''
|
||||
category = classify(base_type, name)
|
||||
type_counts[category] = type_counts.get(category, 0) + cnt
|
||||
total_cards += cnt
|
||||
type_cards.setdefault(category, []).append({
|
||||
'name': name,
|
||||
'count': cnt,
|
||||
'role': info.get('Role', '') or '',
|
||||
'tags': list(info.get('Tags', []) or []),
|
||||
})
|
||||
# Sort cards within each type by name
|
||||
for cat, lst in type_cards.items():
|
||||
lst.sort(key=lambda x: (x['name'].lower(), -int(x['count'])))
|
||||
type_order = sorted(type_counts.keys(), key=lambda k: precedence_index.get(k, 999))
|
||||
|
||||
# Pip distribution (counts and weights) for non-land spells only
|
||||
pip_counts = {c: 0 for c in ('W','U','B','R','G')}
|
||||
import re as _re_local
|
||||
total_pips = 0.0
|
||||
for name, info in self.card_library.items():
|
||||
ctype = str(info.get('Card Type', ''))
|
||||
if 'land' in ctype.lower():
|
||||
continue
|
||||
mana_cost = info.get('Mana Cost') or info.get('mana_cost') or ''
|
||||
if not isinstance(mana_cost, str):
|
||||
continue
|
||||
for match in _re_local.findall(r'\{([^}]+)\}', mana_cost):
|
||||
sym = match.upper()
|
||||
if len(sym) == 1 and sym in pip_counts:
|
||||
pip_counts[sym] += 1
|
||||
total_pips += 1
|
||||
elif '/' in sym:
|
||||
parts = [p for p in sym.split('/') if p in pip_counts]
|
||||
if parts:
|
||||
weight_each = 1 / len(parts)
|
||||
for p in parts:
|
||||
pip_counts[p] += weight_each
|
||||
total_pips += weight_each
|
||||
if total_pips <= 0:
|
||||
# Fallback to even distribution across color identity
|
||||
colors = [c for c in ('W','U','B','R','G') if c in (getattr(self, 'color_identity', []) or [])]
|
||||
if colors:
|
||||
share = 1 / len(colors)
|
||||
for c in colors:
|
||||
pip_counts[c] = share
|
||||
total_pips = 1.0
|
||||
pip_weights = {c: (pip_counts[c] / total_pips if total_pips else 0.0) for c in pip_counts}
|
||||
|
||||
# Mana generation from lands (color sources)
|
||||
try:
|
||||
from deck_builder import builder_utils as _bu
|
||||
matrix = _bu.compute_color_source_matrix(self.card_library, full_df)
|
||||
except Exception:
|
||||
matrix = {}
|
||||
source_counts = {c: 0 for c in ('W','U','B','R','G')}
|
||||
for name, flags in matrix.items():
|
||||
copies = int(self.card_library.get(name, {}).get('Count', 1))
|
||||
for c in source_counts:
|
||||
if int(flags.get(c, 0)):
|
||||
source_counts[c] += copies
|
||||
total_sources = sum(source_counts.values())
|
||||
|
||||
# Mana curve (non-land spells)
|
||||
curve_bins = ['0','1','2','3','4','5','6+']
|
||||
curve_counts = {b: 0 for b in curve_bins}
|
||||
curve_cards: Dict[str, list] = {b: [] for b in curve_bins}
|
||||
total_spells = 0
|
||||
for name, info in self.card_library.items():
|
||||
ctype = str(info.get('Card Type', ''))
|
||||
if 'land' in ctype.lower():
|
||||
continue
|
||||
cnt = int(info.get('Count', 1))
|
||||
mv = info.get('Mana Value')
|
||||
if mv in (None, ''):
|
||||
row = row_lookup.get(name)
|
||||
if row is not None:
|
||||
mv = row.get('manaValue', row.get('cmc', None))
|
||||
try:
|
||||
val = float(mv) if mv not in (None, '') else 0.0
|
||||
except Exception:
|
||||
val = 0.0
|
||||
bucket = '6+' if val >= 6 else str(int(val))
|
||||
if bucket not in curve_counts:
|
||||
bucket = '6+'
|
||||
curve_counts[bucket] += cnt
|
||||
curve_cards[bucket].append({'name': name, 'count': cnt})
|
||||
total_spells += cnt
|
||||
|
||||
return {
|
||||
'type_breakdown': {
|
||||
'counts': type_counts,
|
||||
'order': type_order,
|
||||
'cards': type_cards,
|
||||
'total': total_cards,
|
||||
},
|
||||
'pip_distribution': {
|
||||
'counts': pip_counts,
|
||||
'weights': pip_weights,
|
||||
},
|
||||
'mana_generation': {
|
||||
**source_counts,
|
||||
'total_sources': total_sources,
|
||||
},
|
||||
'mana_curve': {
|
||||
**curve_counts,
|
||||
'total_spells': total_spells,
|
||||
'cards': curve_cards,
|
||||
},
|
||||
'colors': list(getattr(self, 'color_identity', []) or []),
|
||||
}
|
||||
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||
"""Export current decklist to CSV (enriched).
|
||||
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
||||
|
|
@ -208,11 +394,11 @@ class ReportingMixin:
|
|||
owned_set_lower = set()
|
||||
|
||||
for name, info in self.card_library.items():
|
||||
base_type = info.get('Card Type') or info.get('Type','')
|
||||
base_mc = info.get('Mana Cost','')
|
||||
base_mv = info.get('Mana Value', info.get('CMC',''))
|
||||
role = info.get('Role','') or ''
|
||||
tags = info.get('Tags',[]) or []
|
||||
base_type = info.get('Card Type') or info.get('Type', '')
|
||||
base_mc = info.get('Mana Cost', '')
|
||||
base_mv = info.get('Mana Value', info.get('CMC', ''))
|
||||
role = info.get('Role', '') or ''
|
||||
tags = info.get('Tags', []) or []
|
||||
tags_join = '; '.join(tags)
|
||||
text_field = ''
|
||||
colors = ''
|
||||
|
|
@ -260,7 +446,7 @@ class ReportingMixin:
|
|||
owned_flag = 'Y' if (name.lower() in owned_set_lower) else ''
|
||||
rows.append(((prec, name.lower()), [
|
||||
name,
|
||||
info.get('Count',1),
|
||||
info.get('Count', 1),
|
||||
base_type,
|
||||
base_mc,
|
||||
base_mv,
|
||||
|
|
@ -276,6 +462,7 @@ class ReportingMixin:
|
|||
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
|
||||
owned_flag
|
||||
]))
|
||||
|
||||
# Now sort (category precedence, then alphabetical name)
|
||||
rows.sort(key=lambda x: x[0])
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue