mtg_python_deckbuilder/code/deck_builder/phases/phase1_commander.py

245 lines
11 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
from typing import List, Optional
import pandas as pd
from .phase0_core import BracketDefinition, BRACKET_DEFINITIONS # noqa: F401
"""Phase 1: Commander & Tag Selection logic.
Extracted from builder.py to reduce monolith size. All public method names and
signatures preserved; DeckBuilder will delegate to these mixin-style functions.
Provided functions expect `self` to be a DeckBuilder instance exposing:
- input_func, output_func
- load_commander_data, _auto_accept, _gather_candidates, _format_commander_pretty,
_initialize_commander_dict
- commander_name, commander_row, commander_tags, commander_dict
- selected_tags, primary_tag, secondary_tag, tertiary_tag
- bracket_definition related attributes for printing summary
No sideeffect changes.
"""
# (Imports moved to top for lint compliance)
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
direct_hits = [n for n in names if self._auto_accept(query, n)]
if len(direct_hits) == 1:
candidate = direct_hits[0]
self.output_func(f"(Auto match candidate) {candidate}")
if self._present_commander_and_confirm(df, candidate):
self.output_func(f"Confirmed: {candidate}")
return candidate
else:
self.output_func("Not confirmed. Starting over.\n")
continue
candidates = self._gather_candidates(query, names)
if not candidates:
self.output_func("No close matches found. Try again.")
continue
self.output_func("\nTop matches:")
for idx, (n, score) in enumerate(candidates, start=1):
self.output_func(f" {idx}. {n} (score {score})")
self.output_func("Enter number to inspect, 'r' to retry, or type a new name:")
choice = self.input_func("Selection: ").strip()
if choice.lower() == 'r':
continue
if choice.isdigit():
i = int(choice)
if 1 <= i <= len(candidates):
nm = candidates[i - 1][0]
if self._present_commander_and_confirm(df, nm):
self.output_func(f"Confirmed: {nm}")
return nm
else:
self.output_func("Not confirmed. Search again.\n")
continue
else:
self.output_func("Invalid index.")
continue
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]
pretty = self._format_commander_pretty(row)
self.output_func("\n" + pretty)
while True:
resp = self.input_func("Is this the commander you want? (y/n): ").strip().lower()
if resp in ("y", "yes"):
self._apply_commander_selection(row)
return True
if resp in ("n", "no"):
return False
self.output_func("Please enter y or n.")
def _apply_commander_selection(self, row: pd.Series): # type: ignore[override]
self.commander_name = row["name"]
self.commander_row = row
self.commander_tags = list(row.get("themeTags", []) or [])
self._initialize_commander_dict(row)
# ---------------------------
# Tag Prioritization
# ---------------------------
def select_commander_tags(self) -> List[str]: # type: ignore[override]
if not self.commander_name:
self.output_func("No commander chosen yet. Selecting commander first...")
self.choose_commander()
tags = list(dict.fromkeys(self.commander_tags))
if not tags:
self.output_func("Commander has no theme tags available.")
self.selected_tags = []
self.primary_tag = self.secondary_tag = self.tertiary_tag = None
self._update_commander_dict_with_selected_tags()
return self.selected_tags
self.output_func("\nAvailable Theme Tags:")
for i, t in enumerate(tags, 1):
self.output_func(f" {i}. {t}")
self.selected_tags = []
self.primary_tag = self._prompt_tag_choice(tags, "Select PRIMARY tag (required):", allow_stop=False)
self.selected_tags.append(self.primary_tag)
remaining = [t for t in tags if t not in self.selected_tags]
if remaining:
self.secondary_tag = self._prompt_tag_choice(remaining, "Select SECONDARY tag (or 0 to stop here):", allow_stop=True)
if self.secondary_tag:
self.selected_tags.append(self.secondary_tag)
remaining = [t for t in remaining if t != self.secondary_tag]
if remaining and self.secondary_tag:
self.tertiary_tag = self._prompt_tag_choice(remaining, "Select TERTIARY tag (or 0 to stop here):", allow_stop=True)
if self.tertiary_tag:
self.selected_tags.append(self.tertiary_tag)
self.output_func("\nChosen Tags (in priority order):")
if not self.selected_tags:
self.output_func(" (None)")
else:
for idx, tag in enumerate(self.selected_tags, 1):
label = ["Primary", "Secondary", "Tertiary"][idx - 1] if idx <= 3 else f"Tag {idx}"
self.output_func(f" {idx}. {tag} ({label})")
self._update_commander_dict_with_selected_tags()
return self.selected_tags
def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]: # type: ignore[override]
while True:
self.output_func("\nCurrent options:")
for i, t in enumerate(available, 1):
self.output_func(f" {i}. {t}")
if allow_stop:
self.output_func(" 0. Stop (no further tags)")
raw = self.input_func(f"{prompt_text} ").strip()
if allow_stop and raw == "0":
return None
if raw.isdigit():
idx = int(raw)
if 1 <= idx <= len(available):
return available[idx - 1]
matches = [t for t in available if t.lower() == raw.lower()]
if matches:
return matches[0]
self.output_func("Invalid selection. Try again.")
def _update_commander_dict_with_selected_tags(self): # type: ignore[override]
if not self.commander_dict and self.commander_row is not None:
self._initialize_commander_dict(self.commander_row)
if not self.commander_dict:
return
self.commander_dict["Primary Tag"] = self.primary_tag
self.commander_dict["Secondary Tag"] = self.secondary_tag
self.commander_dict["Tertiary Tag"] = self.tertiary_tag
self.commander_dict["Selected Tags"] = self.selected_tags.copy()
# ---------------------------
# Power Bracket Selection
# ---------------------------
def select_power_bracket(self) -> BracketDefinition: # type: ignore[override]
if self.bracket_definition:
return self.bracket_definition
self.output_func("\nChoose Deck Power Bracket:")
for bd in BRACKET_DEFINITIONS:
self.output_func(f" {bd.level}. {bd.name} - {bd.short_desc}")
while True:
raw = self.input_func("Enter bracket number (1-5) or 'info' for details: ").strip().lower()
if raw == "info":
self._print_bracket_details()
continue
if raw.isdigit():
num = int(raw)
match = next((bd for bd in BRACKET_DEFINITIONS if bd.level == num), None)
if match:
self.bracket_definition = match
self.bracket_level = match.level
self.bracket_name = match.name
self.bracket_limits = match.limits.copy()
self.output_func(f"\nSelected Bracket {match.level}: {match.name}")
self._print_selected_bracket_summary()
return match
self.output_func("Invalid input. Type 1-5 or 'info'.")
def _print_bracket_details(self): # type: ignore[override]
self.output_func("\nBracket Details:")
for bd in BRACKET_DEFINITIONS:
self.output_func(f"\n[{bd.level}] {bd.name}")
self.output_func(bd.long_desc)
self.output_func(self._format_limits(bd.limits))
def _print_selected_bracket_summary(self): # type: ignore[override]
self.output_func("\nBracket Constraints:")
if self.bracket_limits:
self.output_func(self._format_limits(self.bracket_limits))
__all__ = [
'CommanderSelectionMixin'
]