mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 22:16:31 +01:00
feat: add Budget Mode with price cache infrastructure and stale price warnings (#61)
This commit is contained in:
parent
1aa8e4d7e8
commit
8643b72108
42 changed files with 6976 additions and 2753 deletions
|
|
@ -1367,6 +1367,68 @@ class DeckBuilder(
|
|||
self._full_cards_df = combined.copy()
|
||||
return combined
|
||||
|
||||
def apply_budget_pool_filter(self) -> None:
|
||||
"""M4: Remove cards priced above the per-card ceiling × (1 + tolerance) from the pool.
|
||||
|
||||
Must be called AFTER budget_config is set on the builder instance.
|
||||
Fail-open: skipped if price column absent, no ceiling configured, or any exception occurs.
|
||||
Include-list cards are never filtered regardless of price.
|
||||
"""
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
budget_config = getattr(self, 'budget_config', None) or {}
|
||||
ceiling = budget_config.get('card_ceiling')
|
||||
if not ceiling or ceiling <= 0:
|
||||
return
|
||||
|
||||
df = getattr(self, '_combined_cards_df', None)
|
||||
if df is None or not hasattr(df, 'columns'):
|
||||
return
|
||||
|
||||
if 'price' not in df.columns:
|
||||
_logger.warning("BUDGET_POOL_FILTER: 'price' column absent — skipping pool filter")
|
||||
return
|
||||
|
||||
# Tolerance: per-build user value > env var > constant default
|
||||
tol = budget_config.get('pool_tolerance')
|
||||
if tol is None:
|
||||
import os as _os
|
||||
env_tol = _os.getenv('BUDGET_POOL_TOLERANCE')
|
||||
try:
|
||||
tol = float(env_tol) if env_tol else bc.BUDGET_POOL_TOLERANCE
|
||||
except ValueError:
|
||||
tol = bc.BUDGET_POOL_TOLERANCE
|
||||
max_price = ceiling * (1.0 + tol)
|
||||
|
||||
# Include-list cards always pass regardless of price
|
||||
include_lower: set[str] = set()
|
||||
try:
|
||||
for nm in (getattr(self, 'include_cards', None) or []):
|
||||
include_lower.add(str(nm).strip().lower())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
before = len(df)
|
||||
try:
|
||||
price_ok = df['price'].isna() | (df['price'] <= max_price)
|
||||
if include_lower and 'name' in df.columns:
|
||||
protected = df['name'].str.strip().str.lower().isin(include_lower)
|
||||
df = df[price_ok | protected]
|
||||
else:
|
||||
df = df[price_ok]
|
||||
except Exception as exc:
|
||||
_logger.error(f"BUDGET_POOL_FILTER: filter failed: {exc}")
|
||||
return
|
||||
|
||||
removed = before - len(df)
|
||||
if removed:
|
||||
_logger.info(
|
||||
f"BUDGET_POOL_FILTER: removed {removed} cards above ${max_price:.2f} "
|
||||
f"(ceiling=${ceiling:.2f}, tol={tol * 100:.0f}%)"
|
||||
)
|
||||
self._combined_cards_df = df
|
||||
|
||||
# ---------------------------
|
||||
# Include/Exclude Processing (M1: Config + Validation + Persistence)
|
||||
# ---------------------------
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ PRICE_CACHE_SIZE: Final[int] = 128 # Size of price check LRU cache
|
|||
PRICE_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds
|
||||
PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance
|
||||
DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card
|
||||
BUDGET_POOL_TOLERANCE: Final[float] = 0.15 # Default pool filter tolerance (15% overhead above per-card ceiling)
|
||||
BUDGET_TOTAL_TOLERANCE: Final[float] = 0.10 # End-of-build review threshold (10% grace on total deck cost)
|
||||
|
||||
# Deck composition defaults
|
||||
DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces
|
||||
|
|
|
|||
|
|
@ -798,18 +798,24 @@ class ReportingMixin:
|
|||
except Exception: # pragma: no cover - diagnostics only
|
||||
logger.debug("Failed to record theme telemetry", exc_info=True)
|
||||
return summary_payload
|
||||
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
|
||||
Included columns: Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, Tags, Text.
|
||||
Falls back gracefully if snapshot rows missing.
|
||||
"""
|
||||
def export_decklist_csv(
|
||||
self,
|
||||
directory: str = 'deck_files',
|
||||
filename: str | None = None,
|
||||
suppress_output: bool = False,
|
||||
price_lookup: Any | None = None,
|
||||
) -> str:
|
||||
"""Export current decklist to CSV (enriched).
|
||||
|
||||
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
||||
Included columns (enriched when possible):
|
||||
Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, Tags, Text
|
||||
Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, Tags, Text, Price
|
||||
Falls back gracefully if snapshot rows missing.
|
||||
|
||||
Args:
|
||||
price_lookup: Optional callable (list[str] -> dict[str, float|None]) used to
|
||||
batch-look up prices at export time. When omitted the Price column
|
||||
is written but left blank for every card.
|
||||
"""
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
def _slug(s: str) -> str:
|
||||
|
|
@ -882,9 +888,18 @@ class ReportingMixin:
|
|||
|
||||
headers = [
|
||||
"Name","Count","Type","ManaCost","ManaValue","Colors","Power","Toughness",
|
||||
"Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned"
|
||||
"Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned","Price"
|
||||
]
|
||||
|
||||
# Batch price lookup (no-op when price_lookup not provided)
|
||||
card_names_list = list(self.card_library.keys())
|
||||
prices_map: Dict[str, Any] = {}
|
||||
if callable(price_lookup):
|
||||
try:
|
||||
prices_map = price_lookup(card_names_list) or {}
|
||||
except Exception:
|
||||
prices_map = {}
|
||||
|
||||
header_suffix: List[str] = []
|
||||
try:
|
||||
commander_meta = self.get_commander_export_metadata()
|
||||
|
|
@ -1024,7 +1039,8 @@ class ReportingMixin:
|
|||
metadata_tags_join, # M5: Include metadata tags
|
||||
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
|
||||
dfc_note,
|
||||
owned_flag
|
||||
owned_flag,
|
||||
(f"{prices_map[name]:.2f}" if prices_map.get(name) is not None else '')
|
||||
]))
|
||||
|
||||
# Now sort (category precedence, then alphabetical name)
|
||||
|
|
@ -1038,6 +1054,19 @@ class ReportingMixin:
|
|||
w.writerow(data_row + suffix_padding)
|
||||
else:
|
||||
w.writerow(data_row)
|
||||
# Summary row: total price in the Price column (blank when no prices available)
|
||||
if prices_map:
|
||||
total_price = sum(
|
||||
v for v in prices_map.values() if v is not None
|
||||
)
|
||||
price_col_index = headers.index('Price')
|
||||
summary_row = [''] * len(headers)
|
||||
summary_row[0] = 'Total'
|
||||
summary_row[price_col_index] = f'{total_price:.2f}'
|
||||
if suffix_padding:
|
||||
w.writerow(summary_row + suffix_padding)
|
||||
else:
|
||||
w.writerow(summary_row)
|
||||
|
||||
self.output_func(f"Deck exported to {fname}")
|
||||
# Auto-generate matching plaintext list (best-effort; ignore failures)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue