2025-08-20 10:46:23 -07:00
|
|
|
|
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 side‑effect changes.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# (Imports moved to top for lint compliance)
|
|
|
|
|
|
|
|
|
|
|
|
class CommanderSelectionMixin:
|
|
|
|
|
|
# ---------------------------
|
|
|
|
|
|
# Commander Selection
|
|
|
|
|
|
# ---------------------------
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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)
|
|
|
|
|
|
|
2025-10-31 08:18:09 -07:00
|
|
|
|
def choose_commander(self) -> str:
|
2025-08-20 10:46:23 -07:00
|
|
|
|
df = self.load_commander_data()
|
|
|
|
|
|
names = df["name"].tolist()
|
|
|
|
|
|
while True:
|
|
|
|
|
|
query = self.input_func("Enter commander name: ").strip()
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
query = self._normalize_commander_query(query)
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
query = self._normalize_commander_query(choice) # treat as new (normalized) query
|
2025-08-20 10:46:23 -07:00
|
|
|
|
|
2025-10-31 08:18:09 -07:00
|
|
|
|
def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool:
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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.")
|
|
|
|
|
|
|
2025-10-31 08:18:09 -07:00
|
|
|
|
def _apply_commander_selection(self, row: pd.Series):
|
2025-08-20 10:46:23 -07:00
|
|
|
|
self.commander_name = row["name"]
|
|
|
|
|
|
self.commander_row = row
|
2025-10-19 13:29:47 -07:00
|
|
|
|
tags_value = row.get("themeTags", [])
|
|
|
|
|
|
self.commander_tags = list(tags_value) if tags_value is not None else []
|
2025-08-20 10:46:23 -07:00
|
|
|
|
self._initialize_commander_dict(row)
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------
|
|
|
|
|
|
# Tag Prioritization
|
|
|
|
|
|
# ---------------------------
|
2025-10-31 08:18:09 -07:00
|
|
|
|
def select_commander_tags(self) -> List[str]:
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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
|
|
|
|
|
|
|
2025-10-31 08:18:09 -07:00
|
|
|
|
def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]:
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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.")
|
|
|
|
|
|
|
2025-10-31 08:18:09 -07:00
|
|
|
|
def _update_commander_dict_with_selected_tags(self):
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------
|
2025-10-31 08:18:09 -07:00
|
|
|
|
def select_power_bracket(self) -> BracketDefinition:
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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'.")
|
|
|
|
|
|
|
2025-10-31 08:18:09 -07:00
|
|
|
|
def _print_bracket_details(self):
|
2025-08-20 10:46:23 -07:00
|
|
|
|
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))
|
|
|
|
|
|
|
2025-10-31 08:18:09 -07:00
|
|
|
|
def _print_selected_bracket_summary(self):
|
2025-08-20 10:46:23 -07:00
|
|
|
|
self.output_func("\nBracket Constraints:")
|
|
|
|
|
|
if self.bracket_limits:
|
|
|
|
|
|
self.output_func(self._format_limits(self.bracket_limits))
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
|
|
|
'CommanderSelectionMixin'
|
|
|
|
|
|
]
|