mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 14:06:31 +01:00
feat: add Budget Mode with price cache infrastructure and stale price warnings
This commit is contained in:
parent
1aa8e4d7e8
commit
ec23775205
42 changed files with 6976 additions and 2753 deletions
|
|
@ -57,6 +57,11 @@ ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
|
||||||
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
|
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
|
||||||
ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1"
|
ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1"
|
||||||
SHOW_MUST_HAVE_BUTTONS=0 # dockerhub: SHOW_MUST_HAVE_BUTTONS="0" (set to 1 to surface must include/exclude buttons)
|
SHOW_MUST_HAVE_BUTTONS=0 # dockerhub: SHOW_MUST_HAVE_BUTTONS="0" (set to 1 to surface must include/exclude buttons)
|
||||||
|
ENABLE_BUDGET_MODE=1 # dockerhub: ENABLE_BUDGET_MODE="1" (1=enable budget mode controls and reporting)
|
||||||
|
BUDGET_POOL_TOLERANCE=0.15 # dockerhub: BUDGET_POOL_TOLERANCE="0.15" (fractional overhead above per-card ceiling before pool exclusion; overridden per-build by UI field)
|
||||||
|
PRICE_AUTO_REFRESH=0 # dockerhub: PRICE_AUTO_REFRESH="0" (1=rebuild price cache daily at 01:00 UTC)
|
||||||
|
PRICE_LAZY_REFRESH=1 # dockerhub: PRICE_LAZY_REFRESH="1" (1=refresh stale per-card prices in background)
|
||||||
|
PRICE_STALE_WARNING_HOURS=24 # dockerhub: PRICE_STALE_WARNING_HOURS="24" (hours before a cached price shows ⏱ stale indicator; 0=disable)
|
||||||
WEB_THEME_PICKER_DIAGNOSTICS=1 # dockerhub: WEB_THEME_PICKER_DIAGNOSTICS="1"
|
WEB_THEME_PICKER_DIAGNOSTICS=1 # dockerhub: WEB_THEME_PICKER_DIAGNOSTICS="1"
|
||||||
ENABLE_CARD_DETAILS=1 # dockerhub: ENABLE_CARD_DETAILS="1"
|
ENABLE_CARD_DETAILS=1 # dockerhub: ENABLE_CARD_DETAILS="1"
|
||||||
SIMILARITY_CACHE_ENABLED=1 # dockerhub: SIMILARITY_CACHE_ENABLED="1"
|
SIMILARITY_CACHE_ENABLED=1 # dockerhub: SIMILARITY_CACHE_ENABLED="1"
|
||||||
|
|
|
||||||
18
.github/workflows/build-similarity-cache.yml
vendored
18
.github/workflows/build-similarity-cache.yml
vendored
|
|
@ -104,6 +104,18 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Refresh price data and write to parquet
|
||||||
|
if: steps.check_cache.outputs.needs_build == 'true'
|
||||||
|
run: |
|
||||||
|
# refresh_prices_parquet() downloads fresh Scryfall bulk data and rebuilds the cache
|
||||||
|
python -c "from code.file_setup.setup import refresh_prices_parquet; refresh_prices_parquet(print)"
|
||||||
|
|
||||||
|
if [ ! -f "card_files/prices_cache.json" ]; then
|
||||||
|
echo "ERROR: Price cache not created"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Price data refreshed successfully"
|
||||||
|
|
||||||
# Debug step - uncomment if needed to inspect Parquet file contents
|
# Debug step - uncomment if needed to inspect Parquet file contents
|
||||||
# - name: Debug - Inspect Parquet file after tagging
|
# - name: Debug - Inspect Parquet file after tagging
|
||||||
# if: steps.check_cache.outputs.needs_build == 'true'
|
# if: steps.check_cache.outputs.needs_build == 'true'
|
||||||
|
|
@ -264,9 +276,11 @@ jobs:
|
||||||
## Files
|
## Files
|
||||||
- `card_files/similarity_cache.parquet` - Pre-computed card similarity cache
|
- `card_files/similarity_cache.parquet` - Pre-computed card similarity cache
|
||||||
- `card_files/similarity_cache_metadata.json` - Cache metadata
|
- `card_files/similarity_cache_metadata.json` - Cache metadata
|
||||||
- `card_files/processed/all_cards.parquet` - Tagged card database
|
- `card_files/processed/all_cards.parquet` - Tagged card database (with prices)
|
||||||
- `card_files/processed/commander_cards.parquet` - Commander-only cache (fast lookups)
|
- `card_files/processed/commander_cards.parquet` - Commander-only cache (fast lookups)
|
||||||
- `card_files/processed/.tagging_complete.json` - Tagging status
|
- `card_files/processed/.tagging_complete.json` - Tagging status
|
||||||
|
- `card_files/prices_cache.json` - Scryfall price cache
|
||||||
|
- `card_files/prices_cache.json.ts` - Per-card price timestamps (if present)
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Start with clean index
|
# Start with clean index
|
||||||
|
|
@ -278,6 +292,8 @@ jobs:
|
||||||
git add -f card_files/processed/all_cards.parquet
|
git add -f card_files/processed/all_cards.parquet
|
||||||
git add -f card_files/processed/commander_cards.parquet
|
git add -f card_files/processed/commander_cards.parquet
|
||||||
git add -f card_files/processed/.tagging_complete.json
|
git add -f card_files/processed/.tagging_complete.json
|
||||||
|
git add -f card_files/prices_cache.json
|
||||||
|
git add -f card_files/prices_cache.json.ts 2>/dev/null || true
|
||||||
git add -f README-cache.md
|
git add -f README-cache.md
|
||||||
|
|
||||||
# Create a new commit
|
# Create a new commit
|
||||||
|
|
|
||||||
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -15,9 +15,39 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
- **Random Mode documentation**: New `docs/random_mode/` directory with `seed_infrastructure.md`, `developer_guide.md`, and `diagnostics.md`
|
- **Random Mode documentation**: New `docs/random_mode/` directory with `seed_infrastructure.md`, `developer_guide.md`, and `diagnostics.md`
|
||||||
- **Multi-copy / Include conflict dialog**: When a known multi-copy archetype card (e.g., Hare Apparent) is typed in the Must Include field of the New Deck modal, a popup now appears asking how many copies to include, with an optional Thrumming Stone checkbox
|
- **Multi-copy / Include conflict dialog**: When a known multi-copy archetype card (e.g., Hare Apparent) is typed in the Must Include field of the New Deck modal, a popup now appears asking how many copies to include, with an optional Thrumming Stone checkbox
|
||||||
- **Multi-copy / Exclude conflict dialog**: When a multi-copy archetype is selected via the Multi-Copy Package selector and the same card also appears in the Must Exclude field, a conflict popup lets you choose to keep the multi-copy (removing it from excludes) or keep the exclude (disabling the archetype selection)
|
- **Multi-copy / Exclude conflict dialog**: When a multi-copy archetype is selected via the Multi-Copy Package selector and the same card also appears in the Must Exclude field, a conflict popup lets you choose to keep the multi-copy (removing it from excludes) or keep the exclude (disabling the archetype selection)
|
||||||
|
- **Budget Mode**: Full budget-aware deck building with price integration
|
||||||
|
- Budget configuration on the New Deck modal: set a total budget cap, optional per-card ceiling, and soft/hard enforcement mode
|
||||||
|
- Price display during building: card prices shown next to card names in list and thumbnail views throughout the build pipeline
|
||||||
|
- Running budget counter chip updates as each build stage completes
|
||||||
|
- Over-budget card highlight: cards exceeding the per-card ceiling are marked with a yellow/gold border
|
||||||
|
- Basic lands excluded from all budget calculations
|
||||||
|
- Budget summary bar in the deck summary view with under/over color coding
|
||||||
|
- Budget badge and over-budget panel on the saved deck view
|
||||||
|
- Pickups list page (`/decks/{name}/pickups`) sorted by priority tier
|
||||||
|
- Pool budget filter: cards exceeding the per-card ceiling by more than the pool tolerance (default 15%, configurable per build in the New Deck modal) are excluded from the candidate pool before building begins
|
||||||
|
- Card price shown in the hover and tap popup for all card tiles with a cached price
|
||||||
|
- Price shown inline on each alternative card suggestion in the alternatives panel
|
||||||
|
- Post-build budget review panel appears when the final deck total exceeds the budget cap by more than 10%; lists over-budget cards sorted by overage with up to 3 cheaper alternatives each
|
||||||
|
- Alternatives in the review panel are matched by card type (lands suggest land alternatives, creatures suggest creature alternatives) and sorted by role similarity using shared strategy tags
|
||||||
|
- Each alternative has a Swap button that replaces the card in the finalized deck and re-evaluates the budget live; the panel auto-dismisses when the total drops within tolerance
|
||||||
|
- "Accept deck as-is" button in soft mode lets you bypass the review and proceed to export
|
||||||
|
- Build complete screen shows a minimal action bar (Restart build / New build / Back) instead of the full stage ribbon
|
||||||
|
- Controlled by `ENABLE_BUDGET_MODE` environment variable (default: enabled)
|
||||||
|
- **Price Cache Infrastructure**: Improved price data lifecycle
|
||||||
|
- `price` and `price_updated` columns added to parquet card database via `refresh_prices_parquet()`
|
||||||
|
- `PRICE_AUTO_REFRESH=1`: optional daily 1 AM UTC scheduled price cache rebuild
|
||||||
|
- `PRICE_LAZY_REFRESH=1`: background per-card price refresh for cards not updated in 7 days (default: enabled)
|
||||||
|
- `POST /api/price/refresh`: manual price cache rebuild trigger
|
||||||
|
- "Card Price Cache Status" section on the Setup page with last-updated date and Refresh button
|
||||||
|
- Footer now shows the price data date alongside the Scryfall attribution
|
||||||
|
- **Price charts**: Visual cost breakdown added to the deck summary and build complete screens
|
||||||
|
- Donut/bar chart showing total deck spend by card role category (commander, ramp, card draw, lands, etc.)
|
||||||
|
- Price histogram showing card count distribution across cost buckets
|
||||||
|
- Basic lands excluded from all chart calculations
|
||||||
|
- **Stale price warnings**: Cards with price data older than 24 hours are flagged with a subtle clock indicator (⏱) on card tiles, the hover popup, the budget review panel, and the Pickups page; if more than half the deck's prices are stale a single banner is shown instead of per-card indicators; controlled by `PRICE_STALE_WARNING_HOURS` (default: 24; set to 0 to disable)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
_No unreleased changes yet_
|
- **Create Button in New Dock Panel**: Button has been renamed to "Build Deck" for consistency with phrasing on the "Quick Build" button
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Multi-copy include count**: Typing an archetype card in Must Include no longer adds only 1 copy — the archetype count is now respected when the dialog is confirmed
|
- **Multi-copy include count**: Typing an archetype card in Must Include no longer adds only 1 copy — the archetype count is now respected when the dialog is confirmed
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,9 @@ See `.env.example` for the full catalog. Common knobs:
|
||||||
| `WEB_VIRTUALIZE` | `1` | Opt-in to virtualized lists/grids for large result sets. |
|
| `WEB_VIRTUALIZE` | `1` | Opt-in to virtualized lists/grids for large result sets. |
|
||||||
| `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. |
|
| `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. |
|
||||||
| `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). |
|
| `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). |
|
||||||
|
| `ENABLE_BUDGET_MODE` | `1` | Enable budget mode controls (total cap, per-card ceiling, soft/hard enforcement) and price display throughout the builder. |
|
||||||
|
| `PRICE_AUTO_REFRESH` | `0` | Rebuild the price cache automatically once daily at 01:00 UTC. |
|
||||||
|
| `PRICE_LAZY_REFRESH` | `1` | Refresh per-card prices in the background when they are more than 7 days old (uses Scryfall named-card API with rate-limit delay). |
|
||||||
| `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). |
|
| `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). |
|
||||||
| `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). |
|
| `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). |
|
||||||
| `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). |
|
| `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). |
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,48 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
- **RandomService**: Service wrapper for seeded RNG with validation (`code/web/services/random_service.py`)
|
- **RandomService**: New `code/web/services/random_service.py` service class wrapping seeded RNG operations with input validation and the R9 `BaseService` pattern
|
||||||
- **Random diagnostics**: `GET /api/random/diagnostics` endpoint (requires `WEB_RANDOM_DIAGNOSTICS=1`)
|
- **InvalidSeedError**: New `InvalidSeedError` exception in `code/exceptions.py` for seed validation failures
|
||||||
- **Random Mode docs**: `docs/random_mode/` covering seed infrastructure, developer guide, and diagnostics
|
- **Random diagnostics endpoint**: `GET /api/random/diagnostics` behind `WEB_RANDOM_DIAGNOSTICS=1` flag, returning seed derivation test vectors for cross-platform consistency checks
|
||||||
- **Multi-copy include dialog**: Typing a multi-copy archetype card (e.g., Hare Apparent) in Must Include now triggers a popup to choose copy count and optional Thrumming Stone inclusion
|
- **Random Mode documentation**: New `docs/random_mode/` directory with `seed_infrastructure.md`, `developer_guide.md`, and `diagnostics.md`
|
||||||
- **Multi-copy/exclude conflict dialog**: Selecting a multi-copy archetype while the same card is in the Exclude list now shows a resolution popup — keep the archetype (removes from excludes) or keep the exclude (disables archetype)
|
- **Multi-copy / Include conflict dialog**: When a known multi-copy archetype card (e.g., Hare Apparent) is typed in the Must Include field of the New Deck modal, a popup now appears asking how many copies to include, with an optional Thrumming Stone checkbox
|
||||||
|
- **Multi-copy / Exclude conflict dialog**: When a multi-copy archetype is selected via the Multi-Copy Package selector and the same card also appears in the Must Exclude field, a conflict popup lets you choose to keep the multi-copy (removing it from excludes) or keep the exclude (disabling the archetype selection)
|
||||||
|
- **Budget Mode**: Full budget-aware deck building with price integration
|
||||||
|
- Budget configuration on the New Deck modal: set a total budget cap, optional per-card ceiling, and soft/hard enforcement mode
|
||||||
|
- Price display during building: card prices shown next to card names in list and thumbnail views throughout the build pipeline
|
||||||
|
- Running budget counter chip updates as each build stage completes
|
||||||
|
- Over-budget card highlight: cards exceeding the per-card ceiling are marked with a yellow/gold border
|
||||||
|
- Basic lands excluded from all budget calculations
|
||||||
|
- Budget summary bar in the deck summary view with under/over color coding
|
||||||
|
- Budget badge and over-budget panel on the saved deck view
|
||||||
|
- Pickups list page (`/decks/{name}/pickups`) sorted by priority tier
|
||||||
|
- Pool budget filter: cards exceeding the per-card ceiling by more than the pool tolerance (default 15%, configurable per build in the New Deck modal) are excluded from the candidate pool before building begins
|
||||||
|
- Card price shown in the hover and tap popup for all card tiles with a cached price
|
||||||
|
- Price shown inline on each alternative card suggestion in the alternatives panel
|
||||||
|
- Post-build budget review panel appears when the final deck total exceeds the budget cap by more than 10%; lists over-budget cards sorted by overage with up to 3 cheaper alternatives each
|
||||||
|
- Alternatives in the review panel are matched by card type (lands suggest land alternatives, creatures suggest creature alternatives) and sorted by role similarity using shared strategy tags
|
||||||
|
- Each alternative has a Swap button that replaces the card in the finalized deck and re-evaluates the budget live; the panel auto-dismisses when the total drops within tolerance
|
||||||
|
- "Accept deck as-is" button in soft mode lets you bypass the review and proceed to export
|
||||||
|
- Build complete screen shows a minimal action bar (Restart build / New build / Back) instead of the full stage ribbon
|
||||||
|
- Controlled by `ENABLE_BUDGET_MODE` environment variable (default: enabled)
|
||||||
|
- **Price Cache Infrastructure**: Improved price data lifecycle
|
||||||
|
- `price` and `price_updated` columns added to parquet card database via `refresh_prices_parquet()`
|
||||||
|
- `PRICE_AUTO_REFRESH=1`: optional daily 1 AM UTC scheduled price cache rebuild
|
||||||
|
- `PRICE_LAZY_REFRESH=1`: background per-card price refresh for cards not updated in 7 days (default: enabled)
|
||||||
|
- `POST /api/price/refresh`: manual price cache rebuild trigger
|
||||||
|
- "Card Price Cache Status" section on the Setup page with last-updated date and Refresh button
|
||||||
|
- Footer now shows the price data date alongside the Scryfall attribution
|
||||||
|
- **Price charts**: Visual cost breakdown added to the deck summary and build complete screens
|
||||||
|
- Donut/bar chart showing total deck spend by card role category (commander, ramp, card draw, lands, etc.)
|
||||||
|
- Price histogram showing card count distribution across cost buckets
|
||||||
|
- Basic lands excluded from all chart calculations
|
||||||
|
- **Stale price warnings**: Cards with price data older than 24 hours are flagged with a subtle clock indicator (⏱) on card tiles, the hover popup, the budget review panel, and the Pickups page; if more than half the deck's prices are stale a single banner is shown instead of per-card indicators; controlled by `PRICE_STALE_WARNING_HOURS` (default: 24; set to 0 to disable)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
_No unreleased changes yet_
|
- **Create Button in New Dock Panel**: Button has been renamed to "Build Deck" for consistency with phrasing on the "Quick Build" button
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Multi-copy include count**: Archetype cards in Must Include now inject the correct count instead of always adding 1 copy
|
- **Multi-copy include count**: Typing an archetype card in Must Include no longer adds only 1 copy — the archetype count is now respected when the dialog is confirmed
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
_No unreleased changes yet_
|
_No unreleased changes yet_
|
||||||
|
|
|
||||||
|
|
@ -1367,6 +1367,68 @@ class DeckBuilder(
|
||||||
self._full_cards_df = combined.copy()
|
self._full_cards_df = combined.copy()
|
||||||
return combined
|
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)
|
# 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_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds
|
||||||
PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance
|
PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance
|
||||||
DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card
|
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
|
# Deck composition defaults
|
||||||
DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces
|
DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces
|
||||||
|
|
|
||||||
|
|
@ -798,18 +798,24 @@ class ReportingMixin:
|
||||||
except Exception: # pragma: no cover - diagnostics only
|
except Exception: # pragma: no cover - diagnostics only
|
||||||
logger.debug("Failed to record theme telemetry", exc_info=True)
|
logger.debug("Failed to record theme telemetry", exc_info=True)
|
||||||
return summary_payload
|
return summary_payload
|
||||||
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
def export_decklist_csv(
|
||||||
"""Export current decklist to CSV (enriched).
|
self,
|
||||||
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
directory: str = 'deck_files',
|
||||||
Included columns: Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, Tags, Text.
|
filename: str | None = None,
|
||||||
Falls back gracefully if snapshot rows missing.
|
suppress_output: bool = False,
|
||||||
"""
|
price_lookup: Any | None = None,
|
||||||
|
) -> str:
|
||||||
"""Export current decklist to CSV (enriched).
|
"""Export current decklist to CSV (enriched).
|
||||||
|
|
||||||
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
||||||
Included columns (enriched when possible):
|
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.
|
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)
|
os.makedirs(directory, exist_ok=True)
|
||||||
def _slug(s: str) -> str:
|
def _slug(s: str) -> str:
|
||||||
|
|
@ -882,9 +888,18 @@ class ReportingMixin:
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
"Name","Count","Type","ManaCost","ManaValue","Colors","Power","Toughness",
|
"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] = []
|
header_suffix: List[str] = []
|
||||||
try:
|
try:
|
||||||
commander_meta = self.get_commander_export_metadata()
|
commander_meta = self.get_commander_export_metadata()
|
||||||
|
|
@ -1024,7 +1039,8 @@ class ReportingMixin:
|
||||||
metadata_tags_join, # M5: Include metadata tags
|
metadata_tags_join, # M5: Include metadata tags
|
||||||
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
|
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
|
||||||
dfc_note,
|
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)
|
# Now sort (category precedence, then alphabetical name)
|
||||||
|
|
@ -1038,6 +1054,19 @@ class ReportingMixin:
|
||||||
w.writerow(data_row + suffix_padding)
|
w.writerow(data_row + suffix_padding)
|
||||||
else:
|
else:
|
||||||
w.writerow(data_row)
|
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}")
|
self.output_func(f"Deck exported to {fname}")
|
||||||
# Auto-generate matching plaintext list (best-effort; ignore failures)
|
# Auto-generate matching plaintext list (best-effort; ignore failures)
|
||||||
|
|
|
||||||
|
|
@ -811,6 +811,33 @@ class IdealDeterminationError(DeckBuilderError):
|
||||||
"""
|
"""
|
||||||
super().__init__(message, code="IDEAL_ERR", details=details)
|
super().__init__(message, code="IDEAL_ERR", details=details)
|
||||||
|
|
||||||
|
class BudgetHardCapExceeded(DeckBuilderError):
|
||||||
|
"""Raised when a deck violates a hard budget cap.
|
||||||
|
|
||||||
|
In hard mode, if the deck total exceeds ``budget_total`` after all
|
||||||
|
feasible replacements, this exception surfaces the violation so callers
|
||||||
|
can surface actionable messaging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
total_price: float,
|
||||||
|
budget_total: float,
|
||||||
|
over_budget_cards: list | None = None,
|
||||||
|
details: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
overage = round(total_price - budget_total, 2)
|
||||||
|
message = (
|
||||||
|
f"Deck cost ${total_price:.2f} exceeds hard budget cap "
|
||||||
|
f"${budget_total:.2f} by ${overage:.2f}"
|
||||||
|
)
|
||||||
|
super().__init__(message, code="BUDGET_HARD_CAP", details=details or {})
|
||||||
|
self.total_price = total_price
|
||||||
|
self.budget_total = budget_total
|
||||||
|
self.overage = overage
|
||||||
|
self.over_budget_cards = over_budget_cards or []
|
||||||
|
|
||||||
|
|
||||||
class PriceConfigurationError(DeckBuilderError):
|
class PriceConfigurationError(DeckBuilderError):
|
||||||
"""Raised when there are issues configuring price settings.
|
"""Raised when there are issues configuring price settings.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -410,3 +410,69 @@ def regenerate_processed_parquet() -> None:
|
||||||
process_raw_parquet(raw_path, processed_path)
|
process_raw_parquet(raw_path, processed_path)
|
||||||
|
|
||||||
logger.info(f"✓ Regenerated {processed_path}")
|
logger.info(f"✓ Regenerated {processed_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_prices_parquet(output_func=None) -> None:
|
||||||
|
"""Rebuild the price cache from local Scryfall bulk data and write
|
||||||
|
``price`` / ``price_updated`` columns into all_cards.parquet and
|
||||||
|
commander_cards.parquet.
|
||||||
|
|
||||||
|
This is safe to call from both the web app and CLI contexts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_func: Optional callable(str) for progress messages. Defaults
|
||||||
|
to the module logger.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
from code.web.services.price_service import get_price_service
|
||||||
|
|
||||||
|
_log = output_func or (lambda msg: logger.info(msg))
|
||||||
|
|
||||||
|
# Download a fresh copy of the Scryfall bulk data before rebuilding.
|
||||||
|
try:
|
||||||
|
from code.file_setup.scryfall_bulk_data import ScryfallBulkDataClient
|
||||||
|
from code.path_util import card_files_raw_dir
|
||||||
|
bulk_path = os.path.join(card_files_raw_dir(), "scryfall_bulk_data.json")
|
||||||
|
_log("Downloading fresh Scryfall bulk data …")
|
||||||
|
client = ScryfallBulkDataClient()
|
||||||
|
info = client.get_bulk_data_info()
|
||||||
|
client.download_bulk_data(info["download_uri"], bulk_path)
|
||||||
|
_log("Scryfall bulk data downloaded.")
|
||||||
|
except Exception as exc:
|
||||||
|
_log(f"Warning: Could not download fresh bulk data ({exc}). Using existing local copy.")
|
||||||
|
|
||||||
|
_log("Rebuilding price cache from Scryfall bulk data …")
|
||||||
|
svc = get_price_service()
|
||||||
|
svc._rebuild_cache()
|
||||||
|
|
||||||
|
processed_path = get_processed_cards_path()
|
||||||
|
if not os.path.exists(processed_path):
|
||||||
|
_log("No processed parquet found — run Setup first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
_log("Loading card database …")
|
||||||
|
df = pd.read_parquet(processed_path)
|
||||||
|
name_col = "faceName" if "faceName" in df.columns else "name"
|
||||||
|
card_names = df[name_col].fillna("").tolist()
|
||||||
|
|
||||||
|
_log(f"Fetching prices for {len(card_names):,} cards …")
|
||||||
|
prices = svc.get_prices_batch(card_names)
|
||||||
|
priced = sum(1 for p in prices.values() if p is not None)
|
||||||
|
|
||||||
|
now_iso = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
df["price"] = df[name_col].map(lambda n: prices.get(n) if n else None)
|
||||||
|
df["price_updated"] = now_iso
|
||||||
|
|
||||||
|
loader = DataLoader()
|
||||||
|
loader.write_cards(df, processed_path)
|
||||||
|
_log(f"Updated all_cards.parquet — {priced:,} of {len(card_names):,} cards priced.")
|
||||||
|
|
||||||
|
# Update commander_cards.parquet by applying the same price columns.
|
||||||
|
processed_dir = os.path.dirname(processed_path)
|
||||||
|
commander_path = os.path.join(processed_dir, "commander_cards.parquet")
|
||||||
|
if os.path.exists(commander_path) and "isCommander" in df.columns:
|
||||||
|
cmd_df = df[df["isCommander"] == True].copy() # noqa: E712
|
||||||
|
loader.write_cards(cmd_df, commander_path)
|
||||||
|
_log(f"Updated commander_cards.parquet ({len(cmd_df):,} commanders).")
|
||||||
|
|
||||||
|
_log("Price refresh complete.")
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,10 @@ SIMILARITY_CACHE_DOWNLOAD = os.getenv('SIMILARITY_CACHE_DOWNLOAD', '1').lower()
|
||||||
# Batch build feature flag (Build X and Compare)
|
# Batch build feature flag (Build X and Compare)
|
||||||
ENABLE_BATCH_BUILD = os.getenv('ENABLE_BATCH_BUILD', '1').lower() not in ('0', 'false', 'off', 'disabled')
|
ENABLE_BATCH_BUILD = os.getenv('ENABLE_BATCH_BUILD', '1').lower() not in ('0', 'false', 'off', 'disabled')
|
||||||
|
|
||||||
|
# M9: Stale price warnings — hours before a per-card price is considered stale.
|
||||||
|
# Set to 0 to disable stale indicators entirely.
|
||||||
|
PRICE_STALE_WARNING_HOURS: int = max(0, int(os.getenv('PRICE_STALE_WARNING_HOURS', '24')))
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------------
|
||||||
# THEME CATALOG SETTINGS
|
# THEME CATALOG SETTINGS
|
||||||
# ----------------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
379
code/tests/test_budget_evaluator.py
Normal file
379
code/tests/test_budget_evaluator.py
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
"""Tests for BudgetEvaluatorService - deck cost evaluation and alternatives."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from code.web.services.budget_evaluator import BudgetEvaluatorService
|
||||||
|
from code.web.services.price_service import PriceService
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers / fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_price_service(prices: Dict[str, Optional[float]]) -> PriceService:
|
||||||
|
"""Return a PriceService stub that returns predefined prices."""
|
||||||
|
svc = MagicMock(spec=PriceService)
|
||||||
|
svc.get_price.side_effect = lambda name, region="usd", foil=False: prices.get(name)
|
||||||
|
svc.get_prices_batch.side_effect = lambda names, region="usd", foil=False: {
|
||||||
|
n: prices.get(n) for n in names
|
||||||
|
}
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
# Shared price table: {card_name: price}
|
||||||
|
KNOWN_PRICES: Dict[str, Optional[float]] = {
|
||||||
|
"Sol Ring": 2.00,
|
||||||
|
"Mana Crypt": 150.00,
|
||||||
|
"Lightning Bolt": 0.25,
|
||||||
|
"Arcane Signet": 1.50,
|
||||||
|
"Fellwar Stone": 1.00,
|
||||||
|
"Command Tower": 0.30,
|
||||||
|
"Swamp": 0.10,
|
||||||
|
"Island": 0.10,
|
||||||
|
"Craterhoof Behemoth": 30.00,
|
||||||
|
"Vampiric Tutor": 25.00,
|
||||||
|
"No Price Card": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def price_svc():
|
||||||
|
return _make_price_service(KNOWN_PRICES)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def evaluator(price_svc):
|
||||||
|
return BudgetEvaluatorService(price_service=price_svc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: evaluate_deck — basic cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_evaluate_under_budget(evaluator):
|
||||||
|
deck = ["Sol Ring", "Arcane Signet", "Command Tower"]
|
||||||
|
# 2.00 + 1.50 + 0.30 = 3.80 < 10.00
|
||||||
|
report = evaluator.evaluate_deck(deck, budget_total=10.0)
|
||||||
|
assert report["budget_status"] == "under"
|
||||||
|
assert report["total_price"] == pytest.approx(3.80)
|
||||||
|
assert report["overage"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_soft_exceeded(evaluator):
|
||||||
|
deck = ["Sol Ring", "Mana Crypt", "Lightning Bolt"]
|
||||||
|
# 2.00 + 150.00 + 0.25 = 152.25 > 100.00
|
||||||
|
report = evaluator.evaluate_deck(deck, budget_total=100.0, mode="soft")
|
||||||
|
assert report["budget_status"] == "soft_exceeded"
|
||||||
|
assert report["overage"] == pytest.approx(52.25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_hard_exceeded(evaluator):
|
||||||
|
deck = ["Sol Ring", "Mana Crypt"]
|
||||||
|
report = evaluator.evaluate_deck(deck, budget_total=50.0, mode="hard")
|
||||||
|
assert report["budget_status"] == "hard_exceeded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_empty_deck(evaluator):
|
||||||
|
report = evaluator.evaluate_deck([], budget_total=100.0)
|
||||||
|
assert report["total_price"] == 0.0
|
||||||
|
assert report["budget_status"] == "under"
|
||||||
|
assert report["overage"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: card_ceiling enforcement
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_card_ceiling_flags_expensive_card(evaluator):
|
||||||
|
deck = ["Sol Ring", "Mana Crypt", "Command Tower"]
|
||||||
|
report = evaluator.evaluate_deck(deck, budget_total=500.0, card_ceiling=10.0)
|
||||||
|
flagged = [e["card"] for e in report["over_budget_cards"]]
|
||||||
|
assert "Mana Crypt" in flagged
|
||||||
|
assert "Sol Ring" not in flagged
|
||||||
|
assert "Command Tower" not in flagged
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_ceiling_not_triggered_under_cap(evaluator):
|
||||||
|
deck = ["Sol Ring", "Arcane Signet"]
|
||||||
|
report = evaluator.evaluate_deck(deck, budget_total=500.0, card_ceiling=5.0)
|
||||||
|
assert report["over_budget_cards"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: include_cards are exempt from over_budget flagging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_include_cards_exempt_from_ceiling(evaluator):
|
||||||
|
deck = ["Mana Crypt", "Sol Ring"]
|
||||||
|
# Mana Crypt (150) is an include — should NOT appear in over_budget_cards
|
||||||
|
report = evaluator.evaluate_deck(
|
||||||
|
deck, budget_total=10.0, card_ceiling=10.0, include_cards=["Mana Crypt"]
|
||||||
|
)
|
||||||
|
flagged = [e["card"] for e in report["over_budget_cards"]]
|
||||||
|
assert "Mana Crypt" not in flagged
|
||||||
|
|
||||||
|
|
||||||
|
def test_include_budget_overage_reported(evaluator):
|
||||||
|
deck = ["Craterhoof Behemoth", "Lightning Bolt"]
|
||||||
|
report = evaluator.evaluate_deck(
|
||||||
|
deck, budget_total=50.0, include_cards=["Craterhoof Behemoth"]
|
||||||
|
)
|
||||||
|
assert report["include_budget_overage"] == pytest.approx(30.00)
|
||||||
|
|
||||||
|
|
||||||
|
def test_include_cards_counted_in_total_price(evaluator):
|
||||||
|
deck = ["Mana Crypt", "Sol Ring"]
|
||||||
|
report = evaluator.evaluate_deck(deck, budget_total=200.0, include_cards=["Mana Crypt"])
|
||||||
|
assert report["total_price"] == pytest.approx(152.00)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: missing price handling (legacy_fail_open)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_missing_price_fail_open_skips(evaluator):
|
||||||
|
deck = ["Sol Ring", "No Price Card"]
|
||||||
|
# No Price Card has no price → treated as 0 in calculation
|
||||||
|
report = evaluator.evaluate_deck(deck, budget_total=100.0, legacy_fail_open=True)
|
||||||
|
assert "No Price Card" in report["stale_prices"]
|
||||||
|
assert report["total_price"] == pytest.approx(2.00)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_price_fail_closed_raises(evaluator):
|
||||||
|
deck = ["No Price Card"]
|
||||||
|
with pytest.raises(ValueError, match="No price data for"):
|
||||||
|
evaluator.evaluate_deck(deck, budget_total=100.0, legacy_fail_open=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: price_breakdown structure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_price_breakdown_contains_all_cards(evaluator):
|
||||||
|
deck = ["Sol Ring", "Lightning Bolt", "Swamp"]
|
||||||
|
report = evaluator.evaluate_deck(deck, budget_total=100.0)
|
||||||
|
names_in_breakdown = [e["card"] for e in report["price_breakdown"]]
|
||||||
|
for card in deck:
|
||||||
|
assert card in names_in_breakdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_breakdown_flags_include(evaluator):
|
||||||
|
deck = ["Mana Crypt", "Sol Ring"]
|
||||||
|
report = evaluator.evaluate_deck(deck, budget_total=200.0, include_cards=["Mana Crypt"])
|
||||||
|
mc_entry = next(e for e in report["price_breakdown"] if e["card"] == "Mana Crypt")
|
||||||
|
assert mc_entry["is_include"] is True
|
||||||
|
sr_entry = next(e for e in report["price_breakdown"] if e["card"] == "Sol Ring")
|
||||||
|
assert sr_entry["is_include"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: find_cheaper_alternatives
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_cheaper_alternatives_respects_max_price():
|
||||||
|
"""Alternatives returned must all be ≤ max_price."""
|
||||||
|
# Build a card index stub with two alternatives
|
||||||
|
candidate_index = {
|
||||||
|
"ramp": [
|
||||||
|
{"name": "Arcane Signet", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
|
||||||
|
{"name": "Cursed Mirror", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
prices = {"Arcane Signet": 1.50, "Cursed Mirror": 8.00}
|
||||||
|
|
||||||
|
svc = _make_price_service(prices)
|
||||||
|
evaluator = BudgetEvaluatorService(price_service=svc)
|
||||||
|
|
||||||
|
with patch("code.web.services.card_index.get_tag_pool") as mock_pool, \
|
||||||
|
patch("code.web.services.card_index.maybe_build_index"):
|
||||||
|
mock_pool.side_effect = lambda tag: candidate_index.get(tag, [])
|
||||||
|
results = evaluator.find_cheaper_alternatives("Mana Crypt", max_price=5.0, tags=["ramp"])
|
||||||
|
|
||||||
|
# Only Arcane Signet (1.50) should qualify; Cursed Mirror (8.00) exceeds max_price
|
||||||
|
names = [r["name"] for r in results]
|
||||||
|
assert "Arcane Signet" in names
|
||||||
|
assert "Cursed Mirror" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_cheaper_alternatives_sorted_by_price():
|
||||||
|
"""Alternatives should be sorted cheapest first."""
|
||||||
|
candidates = [
|
||||||
|
{"name": "Card A", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
|
||||||
|
{"name": "Card B", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
|
||||||
|
{"name": "Card C", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""},
|
||||||
|
]
|
||||||
|
prices = {"Card A": 3.00, "Card B": 1.00, "Card C": 2.00}
|
||||||
|
svc = _make_price_service(prices)
|
||||||
|
evaluator = BudgetEvaluatorService(price_service=svc)
|
||||||
|
|
||||||
|
with patch("code.web.services.card_index.get_tag_pool") as mock_pool, \
|
||||||
|
patch("code.web.services.card_index.maybe_build_index"):
|
||||||
|
mock_pool.return_value = candidates
|
||||||
|
results = evaluator.find_cheaper_alternatives("Mana Crypt", max_price=10.0, tags=["ramp"])
|
||||||
|
|
||||||
|
assert [r["name"] for r in results] == ["Card B", "Card C", "Card A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cheaper_alternatives_empty_when_no_tags():
|
||||||
|
evaluator = BudgetEvaluatorService(price_service=_make_price_service({}))
|
||||||
|
with patch("code.web.services.card_index.maybe_build_index"), \
|
||||||
|
patch("code.web.services.card_index._CARD_INDEX", {}):
|
||||||
|
results = evaluator.find_cheaper_alternatives("Unknown Card", max_price=10.0)
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cheaper_alternatives_color_identity_filter():
|
||||||
|
"""Cards outside the commander's color identity must be excluded."""
|
||||||
|
candidates = [
|
||||||
|
# This card requires White (W) — not in Dimir (U/B)
|
||||||
|
{"name": "Swords to Plowshares", "tags": ["removal"], "color_identity": "W", "color_identity_list": ["W"], "mana_cost": "{W}", "rarity": ""},
|
||||||
|
{"name": "Doom Blade", "tags": ["removal"], "color_identity": "B", "color_identity_list": ["B"], "mana_cost": "{1}{B}", "rarity": ""},
|
||||||
|
]
|
||||||
|
prices = {"Swords to Plowshares": 1.00, "Doom Blade": 0.50}
|
||||||
|
svc = _make_price_service(prices)
|
||||||
|
evaluator = BudgetEvaluatorService(price_service=svc)
|
||||||
|
|
||||||
|
with patch("code.web.services.card_index.get_tag_pool") as mock_pool, \
|
||||||
|
patch("code.web.services.card_index.maybe_build_index"):
|
||||||
|
mock_pool.return_value = candidates
|
||||||
|
results = evaluator.find_cheaper_alternatives(
|
||||||
|
"Vampiric Tutor", max_price=5.0,
|
||||||
|
color_identity=["U", "B"], tags=["removal"]
|
||||||
|
)
|
||||||
|
|
||||||
|
names = [r["name"] for r in results]
|
||||||
|
assert "Swords to Plowshares" not in names
|
||||||
|
assert "Doom Blade" in names
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: calculate_tier_ceilings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_tier_ceilings_correct_fractions(evaluator):
|
||||||
|
ceilings = evaluator.calculate_tier_ceilings(100.0)
|
||||||
|
assert ceilings["S"] == pytest.approx(20.0)
|
||||||
|
assert ceilings["M"] == pytest.approx(10.0)
|
||||||
|
assert ceilings["L"] == pytest.approx(5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_ceilings_zero_budget(evaluator):
|
||||||
|
ceilings = evaluator.calculate_tier_ceilings(0.0)
|
||||||
|
assert all(v == 0.0 for v in ceilings.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: validation guards
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_negative_budget_raises(evaluator):
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
evaluator.evaluate_deck(["Sol Ring"], budget_total=-1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_mode_raises(evaluator):
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
evaluator.evaluate_deck(["Sol Ring"], budget_total=100.0, mode="turbo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_ceiling_raises(evaluator):
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
evaluator.evaluate_deck(["Sol Ring"], budget_total=100.0, card_ceiling=-5.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: BudgetHardCapExceeded exception
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_budget_hard_cap_exception_attributes():
|
||||||
|
from code.exceptions import BudgetHardCapExceeded
|
||||||
|
exc = BudgetHardCapExceeded(
|
||||||
|
total_price=200.0,
|
||||||
|
budget_total=150.0,
|
||||||
|
over_budget_cards=[{"card": "Mana Crypt", "price": 150.0}],
|
||||||
|
)
|
||||||
|
assert exc.overage == pytest.approx(50.0)
|
||||||
|
assert exc.total_price == 200.0
|
||||||
|
assert exc.budget_total == 150.0
|
||||||
|
assert len(exc.over_budget_cards) == 1
|
||||||
|
assert "BUDGET_HARD_CAP" in exc.code
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M8: Price chart helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from code.web.services.budget_evaluator import (
|
||||||
|
compute_price_category_breakdown,
|
||||||
|
compute_price_histogram,
|
||||||
|
CATEGORY_ORDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_breakdown_basic():
|
||||||
|
items = [
|
||||||
|
{"card": "Sol Ring", "price": 2.00, "tags": ["ramp", "mana rock"]},
|
||||||
|
{"card": "Arcane Signet","price": 1.50, "tags": ["ramp", "mana rock"]},
|
||||||
|
{"card": "Swords to Plowshares", "price": 3.00, "tags": ["spot removal", "removal"]},
|
||||||
|
{"card": "Forest", "price": 0.25, "tags": ["land"]},
|
||||||
|
]
|
||||||
|
result = compute_price_category_breakdown(items)
|
||||||
|
assert result["totals"]["Ramp"] == pytest.approx(3.50)
|
||||||
|
assert result["totals"]["Removal"] == pytest.approx(3.00)
|
||||||
|
assert result["totals"]["Land"] == pytest.approx(0.25)
|
||||||
|
assert result["total"] == pytest.approx(6.75)
|
||||||
|
assert result["order"] == CATEGORY_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_breakdown_unmatched_goes_to_other():
|
||||||
|
items = [{"card": "Thassa's Oracle", "price": 10.00, "tags": ["combo", "wincon"]}]
|
||||||
|
result = compute_price_category_breakdown(items)
|
||||||
|
assert result["totals"]["Synergy"] == pytest.approx(10.00)
|
||||||
|
# Specifically "combo" hits Synergy, not Other
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_breakdown_no_price_skipped():
|
||||||
|
items = [
|
||||||
|
{"card": "Card A", "price": None, "tags": ["ramp"]},
|
||||||
|
{"card": "Card B", "price": 5.00, "tags": []},
|
||||||
|
]
|
||||||
|
result = compute_price_category_breakdown(items)
|
||||||
|
assert result["total"] == pytest.approx(5.00)
|
||||||
|
assert result["totals"]["Other"] == pytest.approx(5.00)
|
||||||
|
|
||||||
|
|
||||||
|
def test_histogram_10_bins():
|
||||||
|
items = [{"card": f"Card {i}", "price": float(i)} for i in range(1, 21)]
|
||||||
|
bins = compute_price_histogram(items)
|
||||||
|
assert len(bins) == 10
|
||||||
|
assert all("label" in b and "count" in b and "pct" in b and "color" in b for b in bins)
|
||||||
|
assert sum(b["count"] for b in bins) == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_histogram_all_same_price():
|
||||||
|
items = [{"card": f"Card {i}", "price": 1.00} for i in range(5)]
|
||||||
|
bins = compute_price_histogram(items)
|
||||||
|
assert len(bins) == 10
|
||||||
|
assert bins[0]["count"] == 5
|
||||||
|
assert all(b["count"] == 0 for b in bins[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def test_histogram_fewer_than_2_returns_empty():
|
||||||
|
assert compute_price_histogram([]) == []
|
||||||
|
assert compute_price_histogram([{"card": "Solo", "price": 5.0}]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_histogram_excludes_unpriced_cards():
|
||||||
|
items = [
|
||||||
|
{"card": "A", "price": 1.0},
|
||||||
|
{"card": "B", "price": None},
|
||||||
|
{"card": "C", "price": 3.0},
|
||||||
|
{"card": "D", "price": 5.0},
|
||||||
|
]
|
||||||
|
bins = compute_price_histogram(items)
|
||||||
|
assert sum(b["count"] for b in bins) == 3 # B excluded
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@ This file consolidates tests from three source files:
|
||||||
Created: 2026-02-20
|
Created: 2026-02-20
|
||||||
Consolidation Purpose: Centralize all export and metadata-related tests
|
Consolidation Purpose: Centralize all export and metadata-related tests
|
||||||
|
|
||||||
Total Tests: 21 (4 commander metadata + 2 MDFC + 15 metadata partition)
|
Total Tests: 25 (4 commander metadata + 2 MDFC + 15 metadata partition + 4 price column)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -502,5 +502,110 @@ class TestCSVCompatibility:
|
||||||
assert df_partitioned.loc[0, 'metadataTags'] == ['Applied: Cost Reduction']
|
assert df_partitioned.loc[0, 'metadataTags'] == ['Applied: Cost Reduction']
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION 5: PRICE COLUMN EXPORT TESTS (M7)
|
||||||
|
# Tests for price data in CSV exports
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class _PriceBuilder(ReportingMixin):
|
||||||
|
"""Minimal builder with 3 cards for price export tests."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.card_library = {
|
||||||
|
"Sol Ring": {"Card Type": "Artifact", "Count": 1, "Mana Cost": "{1}", "Mana Value": "1", "Role": "Ramp", "Tags": []},
|
||||||
|
"Counterspell": {"Card Type": "Instant", "Count": 1, "Mana Cost": "{U}{U}", "Mana Value": "2", "Role": "Removal", "Tags": []},
|
||||||
|
"Tropical Island": {"Card Type": "Land", "Count": 1, "Mana Cost": "", "Mana Value": "0", "Role": "Land", "Tags": []},
|
||||||
|
}
|
||||||
|
self.output_func = lambda *_args, **_kwargs: None
|
||||||
|
self.commander_name = ""
|
||||||
|
self.primary_tag = "Spellslinger"
|
||||||
|
self.secondary_tag = None
|
||||||
|
self.tertiary_tag = None
|
||||||
|
self.selected_tags = ["Spellslinger"]
|
||||||
|
self.custom_export_base = "price_test"
|
||||||
|
|
||||||
|
|
||||||
|
def _suppress_cm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
stub = types.ModuleType("deck_builder.builder_utils")
|
||||||
|
stub.compute_color_source_matrix = lambda *_a, **_kw: {}
|
||||||
|
stub.multi_face_land_info = lambda *_a, **_kw: {}
|
||||||
|
monkeypatch.setitem(sys.modules, "deck_builder.builder_utils", stub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_price_column_present_no_lookup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Price column exists even when no price_lookup is provided; values are blank."""
|
||||||
|
_suppress_cm(monkeypatch)
|
||||||
|
b = _PriceBuilder()
|
||||||
|
path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv"))
|
||||||
|
with path.open("r", encoding="utf-8", newline="") as fh:
|
||||||
|
reader = csv.DictReader(fh)
|
||||||
|
assert "Price" in (reader.fieldnames or [])
|
||||||
|
rows = list(reader)
|
||||||
|
card_rows = [r for r in rows if r["Name"] and r["Name"] != "Total"]
|
||||||
|
assert all(r["Price"] == "" for r in card_rows)
|
||||||
|
# No summary row when no prices available
|
||||||
|
assert not any(r["Name"] == "Total" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_price_column_populated_by_lookup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Price column is filled when a price_lookup callable is supplied."""
|
||||||
|
_suppress_cm(monkeypatch)
|
||||||
|
prices = {"Sol Ring": 1.50, "Counterspell": 2.00, "Tropical Island": 100.00}
|
||||||
|
|
||||||
|
def _lookup(names: list) -> dict:
|
||||||
|
return {n: prices.get(n) for n in names}
|
||||||
|
|
||||||
|
b = _PriceBuilder()
|
||||||
|
path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv", price_lookup=_lookup))
|
||||||
|
with path.open("r", encoding="utf-8", newline="") as fh:
|
||||||
|
reader = csv.DictReader(fh)
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
card_rows = {r["Name"]: r for r in rows if r["Name"] and r["Name"] != "Total"}
|
||||||
|
assert card_rows["Sol Ring"]["Price"] == "1.50"
|
||||||
|
assert card_rows["Counterspell"]["Price"] == "2.00"
|
||||||
|
assert card_rows["Tropical Island"]["Price"] == "100.00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_price_summary_row(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A Total row appears with the deck's summed price when prices are available."""
|
||||||
|
_suppress_cm(monkeypatch)
|
||||||
|
|
||||||
|
def _lookup(names: list) -> dict:
|
||||||
|
return {"Sol Ring": 1.50, "Counterspell": 2.00, "Tropical Island": 100.00}
|
||||||
|
|
||||||
|
b = _PriceBuilder()
|
||||||
|
path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv", price_lookup=_lookup))
|
||||||
|
with path.open("r", encoding="utf-8", newline="") as fh:
|
||||||
|
reader = csv.DictReader(fh)
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
total_rows = [r for r in rows if r["Name"] == "Total"]
|
||||||
|
assert len(total_rows) == 1
|
||||||
|
assert total_rows[0]["Price"] == "103.50"
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_price_blank_when_lookup_returns_none(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Cards with no price in the lookup get a blank Price cell, not an error."""
|
||||||
|
_suppress_cm(monkeypatch)
|
||||||
|
|
||||||
|
def _lookup(names: list) -> dict:
|
||||||
|
return {"Sol Ring": 1.50, "Counterspell": None, "Tropical Island": None}
|
||||||
|
|
||||||
|
b = _PriceBuilder()
|
||||||
|
path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv", price_lookup=_lookup))
|
||||||
|
with path.open("r", encoding="utf-8", newline="") as fh:
|
||||||
|
reader = csv.DictReader(fh)
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
card_rows = {r["Name"]: r for r in rows if r["Name"] and r["Name"] != "Total"}
|
||||||
|
assert card_rows["Sol Ring"]["Price"] == "1.50"
|
||||||
|
assert card_rows["Counterspell"]["Price"] == ""
|
||||||
|
assert card_rows["Tropical Island"]["Price"] == ""
|
||||||
|
# Total reflects only priced cards
|
||||||
|
total_rows = [r for r in rows if r["Name"] == "Total"]
|
||||||
|
assert total_rows[0]["Price"] == "1.50"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|
|
||||||
307
code/tests/test_price_service.py
Normal file
307
code/tests/test_price_service.py
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
"""Tests for PriceService - price lookup, caching, and batch operations."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from code.web.services.price_service import PriceService
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_bulk_line(name: str, usd: str = None, eur: str = None, usd_foil: str = None) -> str:
|
||||||
|
"""Return a JSON line matching Scryfall bulk data format."""
|
||||||
|
card: Dict[str, Any] = {
|
||||||
|
"object": "card",
|
||||||
|
"name": name,
|
||||||
|
"prices": {
|
||||||
|
"usd": usd,
|
||||||
|
"usd_foil": usd_foil,
|
||||||
|
"eur": eur,
|
||||||
|
"eur_foil": None,
|
||||||
|
"tix": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return json.dumps(card)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_bulk_data(path: str, cards: list) -> None:
|
||||||
|
"""Write a minimal Scryfall bulk data JSON array (one card per line)."""
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("[\n")
|
||||||
|
for i, card in enumerate(cards):
|
||||||
|
suffix = "," if i < len(cards) - 1 else ""
|
||||||
|
fh.write(json.dumps(card) + suffix + "\n")
|
||||||
|
fh.write("]\n")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bulk_data_file(tmp_path):
|
||||||
|
"""Minimal Scryfall bulk data with known prices."""
|
||||||
|
cards = [
|
||||||
|
{"object": "card", "name": "Lightning Bolt", "prices": {"usd": "0.50", "usd_foil": "2.00", "eur": "0.40", "eur_foil": None, "tix": None}},
|
||||||
|
{"object": "card", "name": "Sol Ring", "prices": {"usd": "2.00", "usd_foil": "5.00", "eur": "1.80", "eur_foil": None, "tix": None}},
|
||||||
|
{"object": "card", "name": "Mana Crypt", "prices": {"usd": "150.00", "usd_foil": "300.00", "eur": "120.00", "eur_foil": None, "tix": None}},
|
||||||
|
# Card with no price
|
||||||
|
{"object": "card", "name": "Unpriced Card", "prices": {"usd": None, "usd_foil": None, "eur": None, "eur_foil": None, "tix": None}},
|
||||||
|
# Second printing of Lightning Bolt (cheaper)
|
||||||
|
{"object": "card", "name": "Lightning Bolt", "prices": {"usd": "0.25", "usd_foil": "1.00", "eur": "0.20", "eur_foil": None, "tix": None}},
|
||||||
|
# DFC card
|
||||||
|
{"object": "card", "name": "Delver of Secrets // Insectile Aberration", "prices": {"usd": "1.50", "usd_foil": "8.00", "eur": "1.20", "eur_foil": None, "tix": None}},
|
||||||
|
]
|
||||||
|
path = str(tmp_path / "scryfall_bulk_data.json")
|
||||||
|
_write_bulk_data(path, cards)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def price_svc(bulk_data_file, tmp_path):
|
||||||
|
"""PriceService pointed at the test bulk data, with a temporary cache path."""
|
||||||
|
cache_path = str(tmp_path / "prices_cache.json")
|
||||||
|
return PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path, cache_ttl=3600)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: single price lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_price_known_card(price_svc):
|
||||||
|
price = price_svc.get_price("Lightning Bolt")
|
||||||
|
# Should return the cheapest printing (0.25, not 0.50)
|
||||||
|
assert price == pytest.approx(0.25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_price_case_insensitive(price_svc):
|
||||||
|
assert price_svc.get_price("lightning bolt") == price_svc.get_price("LIGHTNING BOLT")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_price_foil(price_svc):
|
||||||
|
foil = price_svc.get_price("Lightning Bolt", foil=True)
|
||||||
|
# Cheapest foil printing
|
||||||
|
assert foil == pytest.approx(1.00)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_price_eur_region(price_svc):
|
||||||
|
price = price_svc.get_price("Sol Ring", region="eur")
|
||||||
|
assert price == pytest.approx(1.80)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_price_unknown_card_returns_none(price_svc):
|
||||||
|
assert price_svc.get_price("Nonexistent Card Name XYZ") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_price_unpriced_card_returns_none(price_svc):
|
||||||
|
assert price_svc.get_price("Unpriced Card") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_price_expensive_card(price_svc):
|
||||||
|
assert price_svc.get_price("Mana Crypt") == pytest.approx(150.00)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: DFC card name indexing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_price_dfc_combined_name(price_svc):
|
||||||
|
price = price_svc.get_price("Delver of Secrets // Insectile Aberration")
|
||||||
|
assert price == pytest.approx(1.50)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_price_dfc_front_face_name(price_svc):
|
||||||
|
"""Front face name alone should resolve to the DFC price."""
|
||||||
|
price = price_svc.get_price("Delver of Secrets")
|
||||||
|
assert price == pytest.approx(1.50)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_price_dfc_back_face_name(price_svc):
|
||||||
|
"""Back face name alone should also resolve."""
|
||||||
|
price = price_svc.get_price("Insectile Aberration")
|
||||||
|
assert price == pytest.approx(1.50)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: batch lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_prices_batch_all_found(price_svc):
|
||||||
|
result = price_svc.get_prices_batch(["Lightning Bolt", "Sol Ring"])
|
||||||
|
assert result["Lightning Bolt"] == pytest.approx(0.25)
|
||||||
|
assert result["Sol Ring"] == pytest.approx(2.00)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_prices_batch_mixed_found_missing(price_svc):
|
||||||
|
result = price_svc.get_prices_batch(["Lightning Bolt", "Unknown Card"])
|
||||||
|
assert result["Lightning Bolt"] is not None
|
||||||
|
assert result["Unknown Card"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_prices_batch_empty_list(price_svc):
|
||||||
|
assert price_svc.get_prices_batch([]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_prices_batch_preserves_original_case(price_svc):
|
||||||
|
result = price_svc.get_prices_batch(["LIGHTNING BOLT"])
|
||||||
|
# Key should match input case exactly
|
||||||
|
assert "LIGHTNING BOLT" in result
|
||||||
|
assert result["LIGHTNING BOLT"] == pytest.approx(0.25)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: cache persistence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_rebuild_writes_cache_file(price_svc, tmp_path):
|
||||||
|
# Trigger load → rebuild
|
||||||
|
price_svc.get_price("Sol Ring")
|
||||||
|
assert os.path.exists(price_svc._cache_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_file_has_expected_structure(price_svc, tmp_path):
|
||||||
|
price_svc.get_price("Sol Ring")
|
||||||
|
with open(price_svc._cache_path, "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
assert "prices" in data
|
||||||
|
assert "built_at" in data
|
||||||
|
assert "sol ring" in data["prices"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_cache_loaded_without_rebuild(bulk_data_file, tmp_path):
|
||||||
|
"""Second PriceService instance should load from cache, not rebuild."""
|
||||||
|
cache_path = str(tmp_path / "prices_cache.json")
|
||||||
|
|
||||||
|
svc1 = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path)
|
||||||
|
svc1.get_price("Sol Ring") # triggers rebuild → writes cache
|
||||||
|
|
||||||
|
rebuild_calls = []
|
||||||
|
svc2 = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path)
|
||||||
|
orig_rebuild = svc2._rebuild_cache
|
||||||
|
|
||||||
|
def patched_rebuild():
|
||||||
|
rebuild_calls.append(1)
|
||||||
|
orig_rebuild()
|
||||||
|
|
||||||
|
svc2._rebuild_cache = patched_rebuild
|
||||||
|
svc2.get_price("Sol Ring") # should load from cache, not rebuild
|
||||||
|
|
||||||
|
assert rebuild_calls == [], "Second instance should not rebuild when cache is fresh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_cache_triggers_rebuild(bulk_data_file, tmp_path):
|
||||||
|
"""Cache older than TTL should trigger a rebuild."""
|
||||||
|
cache_path = str(tmp_path / "prices_cache.json")
|
||||||
|
|
||||||
|
# Write a valid but stale cache file
|
||||||
|
stale_data = {
|
||||||
|
"prices": {"sol ring": {"usd": 2.0}},
|
||||||
|
"built_at": time.time() - 99999, # very old
|
||||||
|
}
|
||||||
|
with open(cache_path, "w") as fh:
|
||||||
|
json.dump(stale_data, fh)
|
||||||
|
# Set mtime to old as well
|
||||||
|
old_time = time.time() - 99999
|
||||||
|
os.utime(cache_path, (old_time, old_time))
|
||||||
|
|
||||||
|
rebuild_calls = []
|
||||||
|
svc = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path, cache_ttl=3600)
|
||||||
|
orig_rebuild = svc._rebuild_cache
|
||||||
|
|
||||||
|
def patched_rebuild():
|
||||||
|
rebuild_calls.append(1)
|
||||||
|
orig_rebuild()
|
||||||
|
|
||||||
|
svc._rebuild_cache = patched_rebuild
|
||||||
|
svc.get_price("Sol Ring")
|
||||||
|
|
||||||
|
assert rebuild_calls == [1], "Stale cache should trigger a rebuild"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: cache stats / telemetry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_cache_stats_structure(price_svc):
|
||||||
|
price_svc.get_price("Sol Ring")
|
||||||
|
stats = price_svc.cache_stats()
|
||||||
|
assert "total_entries" in stats
|
||||||
|
assert "hit_count" in stats
|
||||||
|
assert "miss_count" in stats
|
||||||
|
assert "hit_rate" in stats
|
||||||
|
assert "loaded" in stats
|
||||||
|
assert stats["loaded"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_stats_hit_miss_counts(price_svc):
|
||||||
|
price_svc.get_price("Sol Ring") # hit
|
||||||
|
price_svc.get_price("Unknown Card") # miss
|
||||||
|
stats = price_svc.cache_stats()
|
||||||
|
assert stats["hit_count"] >= 1
|
||||||
|
assert stats["miss_count"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_stats_hit_rate_zero_before_load():
|
||||||
|
"""Before any lookups, hit_rate should be 0."""
|
||||||
|
svc = PriceService(bulk_data_path="/nonexistent", cache_path="/nonexistent/cache.json")
|
||||||
|
# Don't trigger _ensure_loaded - call cache_stats indirectly via a direct check
|
||||||
|
# We expect loaded=False and hit_rate=0
|
||||||
|
# Note: cache_stats calls _ensure_loaded, so bulk_data missing → cache remains empty
|
||||||
|
stats = svc.cache_stats()
|
||||||
|
assert stats["hit_rate"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: background refresh
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_refresh_cache_background_starts_thread(price_svc):
|
||||||
|
price_svc.get_price("Sol Ring") # ensure loaded
|
||||||
|
price_svc.refresh_cache_background()
|
||||||
|
# Allow thread to start
|
||||||
|
time.sleep(0.05)
|
||||||
|
# Thread should have run (or be running)
|
||||||
|
assert price_svc._refresh_thread is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_cache_background_no_duplicate_threads(price_svc):
|
||||||
|
price_svc.get_price("Sol Ring")
|
||||||
|
price_svc.refresh_cache_background()
|
||||||
|
t1 = price_svc._refresh_thread
|
||||||
|
price_svc.refresh_cache_background() # second call while thread running
|
||||||
|
t2 = price_svc._refresh_thread
|
||||||
|
assert t1 is t2, "Should not spawn a second refresh thread"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: missing / corrupted bulk data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_missing_bulk_data_returns_none(tmp_path):
|
||||||
|
svc = PriceService(
|
||||||
|
bulk_data_path=str(tmp_path / "nonexistent.json"),
|
||||||
|
cache_path=str(tmp_path / "cache.json"),
|
||||||
|
)
|
||||||
|
assert svc.get_price("Sol Ring") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupted_bulk_data_line_skipped(tmp_path):
|
||||||
|
"""Malformed JSON lines should be skipped without crashing."""
|
||||||
|
bulk_path = str(tmp_path / "bulk.json")
|
||||||
|
with open(bulk_path, "w") as fh:
|
||||||
|
fh.write("[\n")
|
||||||
|
fh.write('{"object":"card","name":"Sol Ring","prices":{"usd":"2.00","usd_foil":null,"eur":null,"eur_foil":null,"tix":null}}\n')
|
||||||
|
fh.write("NOT VALID JSON,,,,\n")
|
||||||
|
fh.write("]")
|
||||||
|
|
||||||
|
svc = PriceService(
|
||||||
|
bulk_data_path=bulk_path,
|
||||||
|
cache_path=str(tmp_path / "cache.json"),
|
||||||
|
)
|
||||||
|
# Should still find Sol Ring despite corrupted line
|
||||||
|
assert svc.get_price("Sol Ring") == pytest.approx(2.00)
|
||||||
|
|
@ -82,6 +82,21 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
|
||||||
get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s)
|
get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Start price auto-refresh scheduler (optional, 1 AM UTC daily)
|
||||||
|
if PRICE_AUTO_REFRESH:
|
||||||
|
try:
|
||||||
|
from .services.price_service import get_price_service
|
||||||
|
from code.file_setup.setup import refresh_prices_parquet
|
||||||
|
get_price_service().start_daily_refresh(hour=1, on_after_rebuild=refresh_prices_parquet)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Start lazy per-card price refresh worker (optional)
|
||||||
|
if PRICE_LAZY_REFRESH:
|
||||||
|
try:
|
||||||
|
from .services.price_service import get_price_service
|
||||||
|
get_price_service().start_lazy_refresh(stale_days=7)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield # (no shutdown tasks currently)
|
yield # (no shutdown tasks currently)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,6 +119,16 @@ if _STATIC_DIR.exists():
|
||||||
# Jinja templates
|
# Jinja templates
|
||||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||||
|
|
||||||
|
# Expose price cache timestamp as a Jinja2 global callable (evaluated per-render)
|
||||||
|
def _price_cache_built_at() -> "str | None":
|
||||||
|
try:
|
||||||
|
from .services.price_service import get_price_service
|
||||||
|
return get_price_service().get_cache_built_at()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
templates.env.globals["_price_cache_ts"] = _price_cache_built_at
|
||||||
|
|
||||||
# Add custom Jinja2 filter for card image URLs
|
# Add custom Jinja2 filter for card image URLs
|
||||||
def card_image_url(card_name: str, size: str = "normal") -> str:
|
def card_image_url(card_name: str, size: str = "normal") -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -178,6 +203,9 @@ ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
||||||
SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False)
|
SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False)
|
||||||
|
ENABLE_BUDGET_MODE = _as_bool(os.getenv("ENABLE_BUDGET_MODE"), True)
|
||||||
|
PRICE_AUTO_REFRESH = _as_bool(os.getenv("PRICE_AUTO_REFRESH"), False)
|
||||||
|
PRICE_LAZY_REFRESH = _as_bool(os.getenv("PRICE_LAZY_REFRESH"), True)
|
||||||
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
|
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
|
||||||
SHOW_THEME_QUALITY_BADGES = _as_bool(os.getenv("SHOW_THEME_QUALITY_BADGES"), True)
|
SHOW_THEME_QUALITY_BADGES = _as_bool(os.getenv("SHOW_THEME_QUALITY_BADGES"), True)
|
||||||
SHOW_THEME_POOL_BADGES = _as_bool(os.getenv("SHOW_THEME_POOL_BADGES"), True)
|
SHOW_THEME_POOL_BADGES = _as_bool(os.getenv("SHOW_THEME_POOL_BADGES"), True)
|
||||||
|
|
@ -2312,6 +2340,7 @@ from .routes import cards as cards_routes # noqa: E402
|
||||||
from .routes import card_browser as card_browser_routes # noqa: E402
|
from .routes import card_browser as card_browser_routes # noqa: E402
|
||||||
from .routes import compare as compare_routes # noqa: E402
|
from .routes import compare as compare_routes # noqa: E402
|
||||||
from .routes import api as api_routes # noqa: E402
|
from .routes import api as api_routes # noqa: E402
|
||||||
|
from .routes import price as price_routes # noqa: E402
|
||||||
app.include_router(build_routes.router)
|
app.include_router(build_routes.router)
|
||||||
app.include_router(build_validation_routes.router, prefix="/build")
|
app.include_router(build_validation_routes.router, prefix="/build")
|
||||||
app.include_router(build_multicopy_routes.router, prefix="/build")
|
app.include_router(build_multicopy_routes.router, prefix="/build")
|
||||||
|
|
@ -2334,6 +2363,7 @@ app.include_router(cards_routes.router)
|
||||||
app.include_router(card_browser_routes.router)
|
app.include_router(card_browser_routes.router)
|
||||||
app.include_router(compare_routes.router)
|
app.include_router(compare_routes.router)
|
||||||
app.include_router(api_routes.router)
|
app.include_router(api_routes.router)
|
||||||
|
app.include_router(price_routes.router)
|
||||||
|
|
||||||
# Warm validation cache early to reduce first-call latency in tests and dev
|
# Warm validation cache early to reduce first-call latency in tests and dev
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,20 @@ async def build_alternatives(
|
||||||
return HTMLResponse(cached)
|
return HTMLResponse(cached)
|
||||||
|
|
||||||
def _render_and_cache(_items: list[dict]):
|
def _render_and_cache(_items: list[dict]):
|
||||||
|
# Enrich each item with USD price from PriceService (best-effort)
|
||||||
|
try:
|
||||||
|
from ..services.price_service import get_price_service
|
||||||
|
_svc = get_price_service()
|
||||||
|
_svc._ensure_loaded()
|
||||||
|
_prices = _svc._cache # keyed by lowercase card name → {usd, usd_foil, ...}
|
||||||
|
for _it in _items:
|
||||||
|
if not _it.get("price"):
|
||||||
|
_nm = (_it.get("name_lower") or str(_it.get("name", ""))).lower()
|
||||||
|
_entry = _prices.get(_nm)
|
||||||
|
if _entry:
|
||||||
|
_it["price"] = _entry.get("usd")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
html_str = templates.get_template("build/_alternatives.html").render({
|
html_str = templates.get_template("build/_alternatives.html").render({
|
||||||
"request": request,
|
"request": request,
|
||||||
"name": name_disp,
|
"name": name_disp,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from ..app import (
|
||||||
ENABLE_BATCH_BUILD,
|
ENABLE_BATCH_BUILD,
|
||||||
DEFAULT_THEME_MATCH_MODE,
|
DEFAULT_THEME_MATCH_MODE,
|
||||||
THEME_POOL_SECTIONS,
|
THEME_POOL_SECTIONS,
|
||||||
|
ENABLE_BUDGET_MODE,
|
||||||
)
|
)
|
||||||
from ..services.build_utils import (
|
from ..services.build_utils import (
|
||||||
step5_ctx_from_result,
|
step5_ctx_from_result,
|
||||||
|
|
@ -113,6 +114,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
||||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||||
|
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||||
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
|
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
|
||||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||||
"form": {
|
"form": {
|
||||||
|
|
@ -425,6 +427,10 @@ async def build_new_submit(
|
||||||
enforcement_mode: str = Form("warn"),
|
enforcement_mode: str = Form("warn"),
|
||||||
allow_illegal: bool = Form(False),
|
allow_illegal: bool = Form(False),
|
||||||
fuzzy_matching: bool = Form(True),
|
fuzzy_matching: bool = Form(True),
|
||||||
|
# Budget (optional)
|
||||||
|
budget_total: float | None = Form(None),
|
||||||
|
card_ceiling: float | None = Form(None),
|
||||||
|
pool_tolerance: str | None = Form(None), # percent string; blank/None treated as 0% (hard cap at ceiling)
|
||||||
# Build count for multi-build
|
# Build count for multi-build
|
||||||
build_count: int = Form(1),
|
build_count: int = Form(1),
|
||||||
# Quick Build flag
|
# Quick Build flag
|
||||||
|
|
@ -474,6 +480,9 @@ async def build_new_submit(
|
||||||
"partner_enabled": partner_form_state["partner_enabled"],
|
"partner_enabled": partner_form_state["partner_enabled"],
|
||||||
"secondary_commander": partner_form_state["secondary_commander"],
|
"secondary_commander": partner_form_state["secondary_commander"],
|
||||||
"background": partner_form_state["background"],
|
"background": partner_form_state["background"],
|
||||||
|
"budget_total": budget_total,
|
||||||
|
"card_ceiling": card_ceiling,
|
||||||
|
"pool_tolerance": pool_tolerance if pool_tolerance is not None else "15",
|
||||||
}
|
}
|
||||||
|
|
||||||
commander_detail = lookup_commander_detail(commander)
|
commander_detail = lookup_commander_detail(commander)
|
||||||
|
|
@ -501,6 +510,7 @@ async def build_new_submit(
|
||||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||||
|
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||||
"form": _form_state(suggested),
|
"form": _form_state(suggested),
|
||||||
"tag_slot_html": None,
|
"tag_slot_html": None,
|
||||||
|
|
@ -527,6 +537,7 @@ async def build_new_submit(
|
||||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||||
|
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||||
"form": _form_state(commander),
|
"form": _form_state(commander),
|
||||||
"tag_slot_html": None,
|
"tag_slot_html": None,
|
||||||
|
|
@ -633,6 +644,7 @@ async def build_new_submit(
|
||||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||||
|
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||||
"form": _form_state(primary_commander_name),
|
"form": _form_state(primary_commander_name),
|
||||||
"tag_slot_html": tag_slot_html,
|
"tag_slot_html": tag_slot_html,
|
||||||
|
|
@ -773,6 +785,7 @@ async def build_new_submit(
|
||||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||||
|
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||||
"form": _form_state(sess.get("commander", "")),
|
"form": _form_state(sess.get("commander", "")),
|
||||||
"tag_slot_html": None,
|
"tag_slot_html": None,
|
||||||
|
|
@ -907,6 +920,30 @@ async def build_new_submit(
|
||||||
import logging
|
import logging
|
||||||
logging.warning(f"Failed to parse exclude cards: {e}")
|
logging.warning(f"Failed to parse exclude cards: {e}")
|
||||||
|
|
||||||
|
# Store budget config in session (only when a total is provided)
|
||||||
|
try:
|
||||||
|
if ENABLE_BUDGET_MODE and budget_total and float(budget_total) > 0:
|
||||||
|
budget_cfg: dict = {"total": float(budget_total), "mode": "soft"}
|
||||||
|
if card_ceiling and float(card_ceiling) > 0:
|
||||||
|
budget_cfg["card_ceiling"] = float(card_ceiling)
|
||||||
|
# pool_tolerance: blank/None → 0.0 (hard cap at ceiling); digit string → float (e.g. "15" → 0.15)
|
||||||
|
# Absence of key in budget_cfg means M4 falls back to the env-level default.
|
||||||
|
_tol = (pool_tolerance or "").strip()
|
||||||
|
try:
|
||||||
|
budget_cfg["pool_tolerance"] = float(_tol) / 100.0 if _tol else 0.0
|
||||||
|
except ValueError:
|
||||||
|
budget_cfg["pool_tolerance"] = 0.15 # bad input → safe default
|
||||||
|
sess["budget_config"] = budget_cfg
|
||||||
|
else:
|
||||||
|
sess.pop("budget_config", None)
|
||||||
|
except Exception:
|
||||||
|
sess.pop("budget_config", None)
|
||||||
|
|
||||||
|
# Assign a stable build ID for this build run (used by JS to detect true new builds,
|
||||||
|
# distinct from per-stage summary_token which increments every stage)
|
||||||
|
import time as _time
|
||||||
|
sess["build_id"] = str(int(_time.time() * 1000))
|
||||||
|
|
||||||
# Clear any old staged build context
|
# Clear any old staged build context
|
||||||
for k in ["build_ctx", "locks", "replace_mode"]:
|
for k in ["build_ctx", "locks", "replace_mode"]:
|
||||||
if k in sess:
|
if k in sess:
|
||||||
|
|
@ -1285,9 +1322,9 @@ def quick_build_progress(request: Request):
|
||||||
# Return Step 5 which will replace the whole wizard div
|
# Return Step 5 which will replace the whole wizard div
|
||||||
response = templates.TemplateResponse("build/_step5.html", ctx)
|
response = templates.TemplateResponse("build/_step5.html", ctx)
|
||||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
# Tell HTMX to target #wizard and swap outerHTML to replace the container
|
# Tell HTMX to target #wizard and swap innerHTML (keeps #wizard in DOM for subsequent interactions)
|
||||||
response.headers["HX-Retarget"] = "#wizard"
|
response.headers["HX-Retarget"] = "#wizard"
|
||||||
response.headers["HX-Reswap"] = "outerHTML"
|
response.headers["HX-Reswap"] = "innerHTML"
|
||||||
return response
|
return response
|
||||||
# Fallback if no result yet
|
# Fallback if no result yet
|
||||||
return HTMLResponse('Build complete. Please refresh.')
|
return HTMLResponse('Build complete. Please refresh.')
|
||||||
|
|
@ -1302,3 +1339,124 @@ def quick_build_progress(request: Request):
|
||||||
response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx)
|
response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx)
|
||||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M5: Budget swap route — swap one card in the finalized deck and re-evaluate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/budget-swap", response_class=HTMLResponse)
|
||||||
|
async def budget_swap(
|
||||||
|
request: Request,
|
||||||
|
old_card: str = Form(...),
|
||||||
|
new_card: str = Form(...),
|
||||||
|
):
|
||||||
|
"""Replace one card in the session budget snapshot and re-render the review panel."""
|
||||||
|
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
|
||||||
|
if not sid:
|
||||||
|
return HTMLResponse("")
|
||||||
|
sess = get_session(sid)
|
||||||
|
|
||||||
|
snapshot: list[str] = list(sess.get("budget_deck_snapshot") or [])
|
||||||
|
if not snapshot:
|
||||||
|
return HTMLResponse("")
|
||||||
|
|
||||||
|
old_lower = old_card.strip().lower()
|
||||||
|
new_name = new_card.strip()
|
||||||
|
replaced = False
|
||||||
|
for i, name in enumerate(snapshot):
|
||||||
|
if name.strip().lower() == old_lower:
|
||||||
|
snapshot[i] = new_name
|
||||||
|
replaced = True
|
||||||
|
break
|
||||||
|
if not replaced:
|
||||||
|
return HTMLResponse("")
|
||||||
|
sess["budget_deck_snapshot"] = snapshot
|
||||||
|
|
||||||
|
# Re-evaluate budget
|
||||||
|
budget_cfg = sess.get("budget_config") or {}
|
||||||
|
try:
|
||||||
|
budget_total = float(budget_cfg.get("total") or 0)
|
||||||
|
except Exception:
|
||||||
|
budget_total = 0.0
|
||||||
|
if budget_total <= 0:
|
||||||
|
return HTMLResponse("")
|
||||||
|
|
||||||
|
budget_mode = str(budget_cfg.get("mode", "soft")).strip().lower()
|
||||||
|
try:
|
||||||
|
card_ceiling = float(budget_cfg.get("card_ceiling")) if budget_cfg.get("card_ceiling") else None
|
||||||
|
except Exception:
|
||||||
|
card_ceiling = None
|
||||||
|
include_cards = [str(c).strip() for c in (sess.get("include_cards") or []) if str(c).strip()]
|
||||||
|
color_identity: list[str] | None = None
|
||||||
|
try:
|
||||||
|
ci_raw = sess.get("color_identity")
|
||||||
|
if ci_raw and isinstance(ci_raw, list):
|
||||||
|
color_identity = [str(c).upper() for c in ci_raw]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..services.budget_evaluator import BudgetEvaluatorService
|
||||||
|
svc = BudgetEvaluatorService()
|
||||||
|
report = svc.evaluate_deck(
|
||||||
|
snapshot,
|
||||||
|
budget_total,
|
||||||
|
mode=budget_mode,
|
||||||
|
card_ceiling=card_ceiling,
|
||||||
|
include_cards=include_cards,
|
||||||
|
color_identity=color_identity,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return HTMLResponse("")
|
||||||
|
|
||||||
|
total_price = float(report.get("total_price", 0.0))
|
||||||
|
tolerance = bc.BUDGET_TOTAL_TOLERANCE
|
||||||
|
over_budget_review = total_price > budget_total * (1.0 + tolerance)
|
||||||
|
|
||||||
|
overage_pct = round((total_price - budget_total) / budget_total * 100, 1) if (budget_total and over_budget_review) else 0.0
|
||||||
|
include_set = {c.lower().strip() for c in include_cards}
|
||||||
|
|
||||||
|
# Use price_breakdown sorted by price desc — most expensive cards contribute most to total overage
|
||||||
|
breakdown = report.get("price_breakdown") or []
|
||||||
|
priced = sorted(
|
||||||
|
[e for e in breakdown
|
||||||
|
if not e.get("is_include") and (e.get("price") is not None) and float(e.get("price") or 0.0) > 0],
|
||||||
|
key=lambda x: -float(x.get("price") or 0.0),
|
||||||
|
)
|
||||||
|
over_cards_out: list[dict] = []
|
||||||
|
for entry in priced[:6]:
|
||||||
|
name = entry.get("card", "")
|
||||||
|
price = float(entry.get("price") or 0.0)
|
||||||
|
is_include = name.lower().strip() in include_set
|
||||||
|
try:
|
||||||
|
alts_raw = svc.find_cheaper_alternatives(
|
||||||
|
name,
|
||||||
|
max_price=max(0.0, price - 0.01),
|
||||||
|
region="usd",
|
||||||
|
color_identity=color_identity,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
alts_raw = []
|
||||||
|
over_cards_out.append({
|
||||||
|
"name": name,
|
||||||
|
"price": price,
|
||||||
|
"swap_disabled": is_include,
|
||||||
|
"alternatives": [
|
||||||
|
{"name": a["name"], "price": a.get("price"), "shared_tags": a.get("shared_tags", [])}
|
||||||
|
for a in alts_raw[:3]
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"request": request,
|
||||||
|
"budget_review_visible": over_budget_review,
|
||||||
|
"over_budget_review": over_budget_review,
|
||||||
|
"budget_review_total": round(total_price, 2),
|
||||||
|
"budget_review_cap": round(budget_total, 2),
|
||||||
|
"budget_overage_pct": overage_pct,
|
||||||
|
"over_budget_cards": over_cards_out,
|
||||||
|
}
|
||||||
|
response = templates.TemplateResponse("build/_budget_review.html", ctx)
|
||||||
|
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return response
|
||||||
|
|
|
||||||
|
|
@ -948,7 +948,10 @@ async def build_step5_start_get(request: Request) -> HTMLResponse:
|
||||||
|
|
||||||
@router.post("/step5/start", response_class=HTMLResponse)
|
@router.post("/step5/start", response_class=HTMLResponse)
|
||||||
async def build_step5_start(request: Request) -> HTMLResponse:
|
async def build_step5_start(request: Request) -> HTMLResponse:
|
||||||
return RedirectResponse("/build", status_code=302)
|
"""(Re)start the build from step 4 review page."""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
commander = sess.get("commander")
|
||||||
if not commander:
|
if not commander:
|
||||||
resp = templates.TemplateResponse(
|
resp = templates.TemplateResponse(
|
||||||
"build/_step1.html",
|
"build/_step1.html",
|
||||||
|
|
@ -957,7 +960,8 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
return resp
|
return resp
|
||||||
try:
|
try:
|
||||||
# Initialize step-by-step build context and run first stage
|
import time as _time
|
||||||
|
sess["build_id"] = str(int(_time.time() * 1000))
|
||||||
sess["build_ctx"] = start_ctx_from_session(sess)
|
sess["build_ctx"] = start_ctx_from_session(sess)
|
||||||
show_skipped = False
|
show_skipped = False
|
||||||
try:
|
try:
|
||||||
|
|
@ -966,18 +970,15 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
|
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
|
||||||
# Save summary to session for deck_summary partial to access
|
|
||||||
if res.get("summary"):
|
if res.get("summary"):
|
||||||
sess["summary"] = res["summary"]
|
sess["summary"] = res["summary"]
|
||||||
status = "Stage complete" if not res.get("done") else "Build complete"
|
status = "Stage complete" if not res.get("done") else "Build complete"
|
||||||
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
|
|
||||||
try:
|
try:
|
||||||
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
|
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
|
||||||
mc = sess.get("multi_copy")
|
mc = sess.get("multi_copy")
|
||||||
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Note: no redirect; the inline compliance panel will render inside Step 5
|
|
||||||
sess["last_step"] = 5
|
sess["last_step"] = 5
|
||||||
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
||||||
resp = templates.TemplateResponse("build/_step5.html", ctx)
|
resp = templates.TemplateResponse("build/_step5.html", ctx)
|
||||||
|
|
@ -985,14 +986,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
||||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Surface a friendly error on the step 5 screen with normalized context
|
err_ctx = step5_error_ctx(request, sess, f"Failed to start build: {e}", include_name=False)
|
||||||
err_ctx = step5_error_ctx(
|
|
||||||
request,
|
|
||||||
sess,
|
|
||||||
f"Failed to start build: {e}",
|
|
||||||
include_name=False,
|
|
||||||
)
|
|
||||||
# Ensure commander stays visible if set
|
|
||||||
err_ctx["commander"] = commander
|
err_ctx["commander"] = commander
|
||||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, Response
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import csv
|
import csv
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from ..app import templates
|
from ..app import templates
|
||||||
from ..services.orchestrator import tags_for_commander
|
from ..services.orchestrator import tags_for_commander
|
||||||
from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx
|
from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx
|
||||||
|
from ..app import ENABLE_BUDGET_MODE
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/decks")
|
router = APIRouter(prefix="/decks")
|
||||||
|
|
@ -402,6 +404,47 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
||||||
"commander_role_label": format_theme_label("Commander"),
|
"commander_role_label": format_theme_label("Commander"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Budget evaluation (only when budget_config is stored in the sidecar meta)
|
||||||
|
if ENABLE_BUDGET_MODE:
|
||||||
|
budget_config = meta_info.get("budget_config") if isinstance(meta_info, dict) else None
|
||||||
|
if isinstance(budget_config, dict) and budget_config.get("total"):
|
||||||
|
try:
|
||||||
|
from ..services.budget_evaluator import BudgetEvaluatorService
|
||||||
|
card_counts = _read_deck_counts(p)
|
||||||
|
decklist = list(card_counts.keys())
|
||||||
|
color_identity = meta_info.get("color_identity") if isinstance(meta_info, dict) else None
|
||||||
|
include_cards = list(meta_info.get("include_cards") or []) if isinstance(meta_info, dict) else []
|
||||||
|
svc = BudgetEvaluatorService()
|
||||||
|
budget_report = svc.evaluate_deck(
|
||||||
|
decklist=decklist,
|
||||||
|
budget_total=float(budget_config["total"]),
|
||||||
|
mode=str(budget_config.get("mode", "soft")),
|
||||||
|
card_ceiling=float(budget_config["card_ceiling"]) if budget_config.get("card_ceiling") else None,
|
||||||
|
color_identity=color_identity,
|
||||||
|
include_cards=include_cards or None,
|
||||||
|
)
|
||||||
|
ctx["budget_report"] = budget_report
|
||||||
|
ctx["budget_config"] = budget_config
|
||||||
|
# M8: Price charts
|
||||||
|
try:
|
||||||
|
from ..services.budget_evaluator import compute_price_category_breakdown, compute_price_histogram
|
||||||
|
_breakdown = budget_report.get("price_breakdown") or []
|
||||||
|
_card_tags: Dict[str, List[str]] = {}
|
||||||
|
if isinstance(summary, dict):
|
||||||
|
_tb = (summary.get("type_breakdown") or {}).get("cards") or {}
|
||||||
|
for _clist in _tb.values():
|
||||||
|
for _c in (_clist or []):
|
||||||
|
if isinstance(_c, dict) and _c.get("name"):
|
||||||
|
_card_tags[_c["name"]] = list(_c.get("tags") or [])
|
||||||
|
_enriched = [{**item, "tags": _card_tags.get(item.get("card", ""), [])} for item in _breakdown]
|
||||||
|
ctx["price_category_chart"] = compute_price_category_breakdown(_enriched)
|
||||||
|
ctx["price_histogram_chart"] = compute_price_histogram(_breakdown)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return templates.TemplateResponse("decks/view.html", ctx)
|
return templates.TemplateResponse("decks/view.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -486,3 +529,144 @@ async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[s
|
||||||
"metaB": metaB,
|
"metaB": metaB,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pickups", response_class=HTMLResponse)
|
||||||
|
async def decks_pickups(request: Request, name: str) -> HTMLResponse:
|
||||||
|
"""Show the pickups list for a deck that was built with budget mode enabled."""
|
||||||
|
base = _deck_dir()
|
||||||
|
p = (base / name).resolve()
|
||||||
|
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"decks/index.html",
|
||||||
|
{"request": request, "items": _list_decks(), "error": "Deck not found."},
|
||||||
|
)
|
||||||
|
|
||||||
|
meta_info: Dict[str, Any] = {}
|
||||||
|
commander_name = ""
|
||||||
|
sidecar = p.with_suffix(".summary.json")
|
||||||
|
if sidecar.exists():
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
payload = _json.loads(sidecar.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
meta_info = payload.get("meta") or {}
|
||||||
|
commander_name = meta_info.get("commander") or ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
budget_config = meta_info.get("budget_config") if isinstance(meta_info, dict) else None
|
||||||
|
budget_report = None
|
||||||
|
error_msg = None
|
||||||
|
|
||||||
|
if not ENABLE_BUDGET_MODE:
|
||||||
|
error_msg = "Budget mode is not enabled (set ENABLE_BUDGET_MODE=1)."
|
||||||
|
elif not isinstance(budget_config, dict) or not budget_config.get("total"):
|
||||||
|
error_msg = "Budget mode was not enabled when this deck was built."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from ..services.budget_evaluator import BudgetEvaluatorService
|
||||||
|
card_counts = _read_deck_counts(p)
|
||||||
|
decklist = list(card_counts.keys())
|
||||||
|
color_identity = meta_info.get("color_identity") if isinstance(meta_info, dict) else None
|
||||||
|
include_cards = list(meta_info.get("include_cards") or []) if isinstance(meta_info, dict) else []
|
||||||
|
svc = BudgetEvaluatorService()
|
||||||
|
budget_report = svc.evaluate_deck(
|
||||||
|
decklist=decklist,
|
||||||
|
budget_total=float(budget_config["total"]),
|
||||||
|
mode=str(budget_config.get("mode", "soft")),
|
||||||
|
card_ceiling=float(budget_config["card_ceiling"]) if budget_config.get("card_ceiling") else None,
|
||||||
|
color_identity=color_identity,
|
||||||
|
include_cards=include_cards or None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
error_msg = f"Budget evaluation failed: {exc}"
|
||||||
|
|
||||||
|
stale_prices: set[str] = set()
|
||||||
|
stale_prices_global = False
|
||||||
|
try:
|
||||||
|
from ..services.price_service import get_price_service
|
||||||
|
from code.settings import PRICE_STALE_WARNING_HOURS
|
||||||
|
_psvc = get_price_service()
|
||||||
|
_psvc._ensure_loaded()
|
||||||
|
if PRICE_STALE_WARNING_HOURS > 0:
|
||||||
|
_stale = _psvc.get_stale_cards(PRICE_STALE_WARNING_HOURS)
|
||||||
|
if _stale and len(_stale) > len(_psvc._cache) * 0.5:
|
||||||
|
stale_prices_global = True
|
||||||
|
else:
|
||||||
|
stale_prices = _stale
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"decks/pickups.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"name": p.name,
|
||||||
|
"commander": commander_name,
|
||||||
|
"budget_config": budget_config,
|
||||||
|
"budget_report": budget_report,
|
||||||
|
"error": error_msg,
|
||||||
|
"stale_prices": stale_prices,
|
||||||
|
"stale_prices_global": stale_prices_global,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/download-csv")
|
||||||
|
async def decks_download_csv(name: str) -> Response:
|
||||||
|
"""Serve a CSV export with live prices fetched at download time."""
|
||||||
|
base = _deck_dir()
|
||||||
|
p = (base / name).resolve()
|
||||||
|
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
|
||||||
|
return HTMLResponse("File not found", status_code=404)
|
||||||
|
try:
|
||||||
|
with p.open("r", encoding="utf-8", newline="") as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
headers = next(reader, [])
|
||||||
|
data_rows = list(reader)
|
||||||
|
except Exception:
|
||||||
|
return HTMLResponse("Could not read CSV", status_code=500)
|
||||||
|
|
||||||
|
# Strip any stale baked Price column
|
||||||
|
if "Price" in headers:
|
||||||
|
price_idx = headers.index("Price")
|
||||||
|
headers = [h for i, h in enumerate(headers) if i != price_idx]
|
||||||
|
data_rows = [[v for i, v in enumerate(row) if i != price_idx] for row in data_rows]
|
||||||
|
|
||||||
|
name_idx = headers.index("Name") if "Name" in headers else 0
|
||||||
|
card_names = [
|
||||||
|
row[name_idx] for row in data_rows
|
||||||
|
if row and len(row) > name_idx and row[name_idx] and row[name_idx] != "Total"
|
||||||
|
]
|
||||||
|
|
||||||
|
prices_map: Dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
from ..services.price_service import get_price_service
|
||||||
|
prices_map = get_price_service().get_prices_batch(card_names) or {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(headers + ["Price"])
|
||||||
|
for row in data_rows:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
name_val = row[name_idx] if len(row) > name_idx else ""
|
||||||
|
if name_val == "Total":
|
||||||
|
continue
|
||||||
|
price_val = prices_map.get(name_val)
|
||||||
|
writer.writerow(row + [f"{price_val:.2f}" if price_val is not None else ""])
|
||||||
|
|
||||||
|
if prices_map:
|
||||||
|
total = sum(v for v in prices_map.values() if v is not None)
|
||||||
|
empty = [""] * len(headers)
|
||||||
|
empty[name_idx] = "Total"
|
||||||
|
writer.writerow(empty + [f"{total:.2f}"])
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=output.getvalue().encode("utf-8"),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{p.name}"'},
|
||||||
|
)
|
||||||
|
|
|
||||||
111
code/web/routes/price.py
Normal file
111
code/web/routes/price.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""Price API routes for card price lookups.
|
||||||
|
|
||||||
|
Provides endpoints for single-card and batch price queries backed by
|
||||||
|
the PriceService (Scryfall bulk data + JSON cache).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import List, Optional
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from code.web.services.price_service import get_price_service
|
||||||
|
from code.web.decorators.telemetry import track_route_access, log_route_errors
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/price")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
@track_route_access("price_cache_stats")
|
||||||
|
async def price_cache_stats():
|
||||||
|
"""Return cache telemetry for the PriceService."""
|
||||||
|
svc = get_price_service()
|
||||||
|
return JSONResponse(svc.cache_stats())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
@track_route_access("price_cache_refresh")
|
||||||
|
async def refresh_price_cache():
|
||||||
|
"""Trigger a background rebuild of the price cache and parquet price columns.
|
||||||
|
|
||||||
|
Returns immediately — the rebuild runs in a daemon thread.
|
||||||
|
"""
|
||||||
|
def _run() -> None:
|
||||||
|
try:
|
||||||
|
from code.file_setup.setup import refresh_prices_parquet
|
||||||
|
refresh_prices_parquet()
|
||||||
|
except Exception as exc:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).error("Manual price refresh failed: %s", exc)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_run, daemon=True, name="price-manual-refresh")
|
||||||
|
t.start()
|
||||||
|
return JSONResponse({"ok": True, "message": "Price cache refresh started in background."})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{card_name:path}")
|
||||||
|
@track_route_access("price_lookup")
|
||||||
|
@log_route_errors("price_lookup")
|
||||||
|
async def get_card_price(
|
||||||
|
card_name: str,
|
||||||
|
region: str = Query("usd", pattern="^(usd|eur)$"),
|
||||||
|
foil: bool = Query(False),
|
||||||
|
):
|
||||||
|
"""Look up the price for a single card.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_name: Card name (URL-encoded, case-insensitive).
|
||||||
|
region: Price region — ``usd`` or ``eur``.
|
||||||
|
foil: If true, return the foil price.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with ``card_name``, ``price`` (float or null), ``region``,
|
||||||
|
``foil``, ``found`` (bool).
|
||||||
|
"""
|
||||||
|
name = unquote(card_name).strip()
|
||||||
|
svc = get_price_service()
|
||||||
|
price = svc.get_price(name, region=region, foil=foil)
|
||||||
|
return JSONResponse({
|
||||||
|
"card_name": name,
|
||||||
|
"price": price,
|
||||||
|
"region": region,
|
||||||
|
"foil": foil,
|
||||||
|
"found": price is not None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch")
|
||||||
|
@track_route_access("price_batch_lookup")
|
||||||
|
@log_route_errors("price_batch_lookup")
|
||||||
|
async def get_prices_batch(
|
||||||
|
card_names: List[str] = Body(..., max_length=100),
|
||||||
|
region: str = Query("usd", pattern="^(usd|eur)$"),
|
||||||
|
foil: bool = Query(False),
|
||||||
|
):
|
||||||
|
"""Look up prices for multiple cards in a single request.
|
||||||
|
|
||||||
|
Request body: JSON array of card name strings (max 100).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_names: List of card names.
|
||||||
|
region: Price region — ``usd`` or ``eur``.
|
||||||
|
foil: If true, return foil prices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with ``prices`` (dict name→float|null) and ``missing`` (list
|
||||||
|
of names with no price data).
|
||||||
|
"""
|
||||||
|
svc = get_price_service()
|
||||||
|
prices = svc.get_prices_batch(card_names, region=region, foil=foil)
|
||||||
|
missing = [n for n, p in prices.items() if p is None]
|
||||||
|
return JSONResponse({
|
||||||
|
"prices": prices,
|
||||||
|
"missing": missing,
|
||||||
|
"region": region,
|
||||||
|
"foil": foil,
|
||||||
|
"total": len(card_names),
|
||||||
|
"found": len(card_names) - len(missing),
|
||||||
|
})
|
||||||
|
|
@ -196,10 +196,14 @@ async def download_github():
|
||||||
async def setup_index(request: Request) -> HTMLResponse:
|
async def setup_index(request: Request) -> HTMLResponse:
|
||||||
import code.settings as settings
|
import code.settings as settings
|
||||||
from code.file_setup.image_cache import ImageCache
|
from code.file_setup.image_cache import ImageCache
|
||||||
|
from code.web.services.price_service import get_price_service
|
||||||
|
from code.web.app import PRICE_AUTO_REFRESH
|
||||||
|
|
||||||
image_cache = ImageCache()
|
image_cache = ImageCache()
|
||||||
return templates.TemplateResponse("setup/index.html", {
|
return templates.TemplateResponse("setup/index.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES,
|
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES,
|
||||||
"image_cache_enabled": image_cache.is_enabled()
|
"image_cache_enabled": image_cache.is_enabled(),
|
||||||
|
"price_cache_built_at": get_price_service().get_cache_built_at(),
|
||||||
|
"price_auto_refresh": PRICE_AUTO_REFRESH,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
692
code/web/services/budget_evaluator.py
Normal file
692
code/web/services/budget_evaluator.py
Normal file
|
|
@ -0,0 +1,692 @@
|
||||||
|
"""Budget evaluation service for deck cost analysis.
|
||||||
|
|
||||||
|
Evaluates a deck against a budget constraint, identifies over-budget cards,
|
||||||
|
finds cheaper alternatives (same tags + color identity, lower price), and
|
||||||
|
produces a BudgetReport with replacements, per-card breakdown, and a
|
||||||
|
pickups list for targeted acquisition.
|
||||||
|
|
||||||
|
Priority order (highest to lowest):
|
||||||
|
exclude > include > budget > bracket
|
||||||
|
|
||||||
|
Include-list cards are never auto-replaced; their cost is reported separately
|
||||||
|
as ``include_budget_overage``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from code.web.services.base import BaseService
|
||||||
|
from code.web.services.price_service import PriceService, get_price_service
|
||||||
|
from code import logging_util
|
||||||
|
|
||||||
|
logger = logging_util.logging.getLogger(__name__)
|
||||||
|
logger.setLevel(logging_util.LOG_LEVEL)
|
||||||
|
logger.addHandler(logging_util.file_handler)
|
||||||
|
logger.addHandler(logging_util.stream_handler)
|
||||||
|
|
||||||
|
# Splurge tier ceilings as a fraction of the total budget.
|
||||||
|
# S = top 20 %, M = top 10 %, L = top 5 %
|
||||||
|
_TIER_FRACTIONS = {"S": 0.20, "M": 0.10, "L": 0.05}
|
||||||
|
|
||||||
|
# How many alternatives to return per card at most.
|
||||||
|
_MAX_ALTERNATIVES = 5
|
||||||
|
|
||||||
|
# Ordered broad MTG card types — first match wins for type detection.
|
||||||
|
_BROAD_TYPES = ("Land", "Creature", "Planeswalker", "Battle", "Enchantment", "Artifact", "Instant", "Sorcery")
|
||||||
|
|
||||||
|
# M8: Build stage category order and tag patterns for category spend breakdown.
|
||||||
|
CATEGORY_ORDER = ["Land", "Ramp", "Creature", "Card Draw", "Removal", "Wipe", "Protection", "Synergy", "Other"]
|
||||||
|
_CATEGORY_COLORS: Dict[str, str] = {
|
||||||
|
"Land": "#94a3b8",
|
||||||
|
"Ramp": "#34d399",
|
||||||
|
"Creature": "#fb923c",
|
||||||
|
"Card Draw": "#60a5fa",
|
||||||
|
"Removal": "#f87171",
|
||||||
|
"Wipe": "#dc2626",
|
||||||
|
"Protection": "#06b6d4",
|
||||||
|
"Synergy": "#c084fc",
|
||||||
|
"Other": "#f59e0b",
|
||||||
|
}
|
||||||
|
# Creature is handled via broad_type fallback (after tag patterns), not listed here
|
||||||
|
_CATEGORY_PATTERNS: List[tuple] = [
|
||||||
|
("Land", ["land"]),
|
||||||
|
("Ramp", ["ramp", "mana rock", "mana dork", "mana acceleration", "mana production"]),
|
||||||
|
("Card Draw", ["card draw", "draw", "card advantage", "cantrip", "looting", "cycling"]),
|
||||||
|
("Removal", ["removal", "spot removal", "bounce", "exile"]),
|
||||||
|
("Wipe", ["board wipe", "sweeper", "wrath"]),
|
||||||
|
("Protection", ["protection", "counterspell", "hexproof", "shroud", "indestructible", "ward"]),
|
||||||
|
("Synergy", ["synergy", "combo", "payoff", "enabler"]),
|
||||||
|
]
|
||||||
|
def _fmt_price_label(price: float) -> str:
|
||||||
|
"""Short x-axis label for a histogram bin boundary."""
|
||||||
|
if price <= 0:
|
||||||
|
return "$0"
|
||||||
|
if price < 1.0:
|
||||||
|
return f"${price:.2f}"
|
||||||
|
if price < 10.0:
|
||||||
|
return f"${price:.1f}" if price != int(price) else f"${int(price)}"
|
||||||
|
return f"${price:.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
# Basic land names excluded from price histogram (their prices are ~$0 and skew the chart)
|
||||||
|
_BASIC_LANDS: frozenset = frozenset({
|
||||||
|
"Plains", "Island", "Swamp", "Mountain", "Forest", "Wastes",
|
||||||
|
"Snow-Covered Plains", "Snow-Covered Island", "Snow-Covered Swamp",
|
||||||
|
"Snow-Covered Mountain", "Snow-Covered Forest", "Snow-Covered Wastes",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Green → amber gradient for 10 histogram bins (cheap → expensive)
|
||||||
|
_HIST_COLORS = [
|
||||||
|
"#34d399", "#3fda8e", "#5de087", "#92e77a", "#c4e66a",
|
||||||
|
"#f0e05a", "#f5c840", "#f5ab2a", "#f59116", "#f59e0b",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def compute_price_category_breakdown(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Aggregate per-card prices into build stage buckets for M8 stacked bar chart.
|
||||||
|
|
||||||
|
Each item should have: {card, price, tags: list[str], broad_type (optional)}.
|
||||||
|
Returns {"totals": {cat: float}, "colors": {cat: hex}, "total": float, "order": [...]}.
|
||||||
|
"""
|
||||||
|
totals: Dict[str, float] = {cat: 0.0 for cat in CATEGORY_ORDER}
|
||||||
|
for item in items:
|
||||||
|
price = item.get("price")
|
||||||
|
if price is None:
|
||||||
|
continue
|
||||||
|
tags_lower = [str(t).lower() for t in (item.get("tags") or [])]
|
||||||
|
broad_type = str(item.get("broad_type") or "").lower()
|
||||||
|
matched = "Other"
|
||||||
|
# Land check first — use broad_type or tag
|
||||||
|
if broad_type == "land" or any("land" in t for t in tags_lower):
|
||||||
|
matched = "Land"
|
||||||
|
else:
|
||||||
|
for cat, patterns in _CATEGORY_PATTERNS[1:]: # skip Land; Creature handled below
|
||||||
|
if any(any(p in t for p in patterns) for t in tags_lower):
|
||||||
|
matched = cat
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if broad_type == "creature":
|
||||||
|
matched = "Creature"
|
||||||
|
totals[matched] = round(totals[matched] + float(price), 2)
|
||||||
|
|
||||||
|
grand_total = round(sum(totals.values()), 2)
|
||||||
|
return {"totals": totals, "colors": _CATEGORY_COLORS, "total": grand_total, "order": CATEGORY_ORDER}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_price_histogram(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Compute a 10-bin price distribution histogram for M8.
|
||||||
|
|
||||||
|
Uses logarithmic bin boundaries when the price range spans >4x (typical for
|
||||||
|
MTG decks) so cheap cards are spread across multiple narrow bins rather than
|
||||||
|
all landing in bin 0. Bar heights use sqrt scaling for a quick-glance view
|
||||||
|
where even a bin with 1 card is still visibly present.
|
||||||
|
|
||||||
|
Items: list of {card, price, ...}. Cards without price are excluded.
|
||||||
|
Basic lands are excluded (their near-zero prices skew the distribution).
|
||||||
|
Returns [] if fewer than 2 priced cards.
|
||||||
|
Each entry: {label, range_min, range_max, x_label, count, pct, color, cards}.
|
||||||
|
"""
|
||||||
|
priced_items = [
|
||||||
|
item for item in items
|
||||||
|
if item.get("price") is not None and item.get("card", "") not in _BASIC_LANDS
|
||||||
|
]
|
||||||
|
prices = [float(item["price"]) for item in priced_items]
|
||||||
|
if len(prices) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
min_p = min(prices)
|
||||||
|
max_p = max(prices)
|
||||||
|
|
||||||
|
def _card_entry(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {"name": item["card"], "price": float(item["price"])}
|
||||||
|
|
||||||
|
if max_p == min_p:
|
||||||
|
# All same price — single populated bin, rest empty
|
||||||
|
all_cards = sorted([_card_entry(it) for it in priced_items], key=lambda c: c["price"])
|
||||||
|
bins: List[Dict[str, Any]] = []
|
||||||
|
for i in range(10):
|
||||||
|
bins.append({
|
||||||
|
"label": f"{min_p:.2f}",
|
||||||
|
"range_min": min_p,
|
||||||
|
"range_max": max_p,
|
||||||
|
"x_label": _fmt_price_label(min_p) + "\u2013" + _fmt_price_label(max_p),
|
||||||
|
"count": len(prices) if i == 0 else 0,
|
||||||
|
"pct": 100 if i == 0 else 0,
|
||||||
|
"color": _HIST_COLORS[i],
|
||||||
|
"cards": all_cards if i == 0 else [],
|
||||||
|
})
|
||||||
|
return bins
|
||||||
|
|
||||||
|
# Choose bin boundary strategy: log-scale when range spans >4x, else linear.
|
||||||
|
# Clamp lower floor to 0.01 so log doesn't blow up on near-zero prices.
|
||||||
|
log_floor = max(min_p, 0.01)
|
||||||
|
use_log = (max_p / log_floor) > 4.0
|
||||||
|
|
||||||
|
if use_log:
|
||||||
|
log_lo = math.log(log_floor)
|
||||||
|
log_hi = math.log(max(max_p, log_floor * 1.001))
|
||||||
|
log_step = (log_hi - log_lo) / 10
|
||||||
|
boundaries = [math.exp(log_lo + i * log_step) for i in range(11)]
|
||||||
|
boundaries[0] = min(boundaries[0], min_p) # don't drop cards below float rounding
|
||||||
|
boundaries[10] = max_p # prevent exp(log(x)) != x float drift from losing last card
|
||||||
|
else:
|
||||||
|
step = (max_p - min_p) / 10
|
||||||
|
boundaries = [min_p + i * step for i in range(11)]
|
||||||
|
|
||||||
|
max_count = 0
|
||||||
|
raw_bins: List[Dict[str, Any]] = []
|
||||||
|
for i in range(10):
|
||||||
|
lo = boundaries[i]
|
||||||
|
hi = boundaries[i + 1]
|
||||||
|
if i < 9:
|
||||||
|
bin_items = [it for it in priced_items if lo <= float(it["price"]) < hi]
|
||||||
|
else:
|
||||||
|
bin_items = [it for it in priced_items if lo <= float(it["price"]) <= hi]
|
||||||
|
count = len(bin_items)
|
||||||
|
max_count = max(max_count, count)
|
||||||
|
raw_bins.append({
|
||||||
|
"label": f"{lo:.2f}~{hi:.2f}",
|
||||||
|
"range_min": round(lo, 2),
|
||||||
|
"range_max": round(hi, 2),
|
||||||
|
"x_label": _fmt_price_label(lo) + "\u2013" + _fmt_price_label(hi),
|
||||||
|
"count": count,
|
||||||
|
"color": _HIST_COLORS[i],
|
||||||
|
"cards": sorted([_card_entry(it) for it in bin_items], key=lambda c: c["price"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sqrt scaling: a bin with 1 card still shows ~13% height vs ~2% with linear.
|
||||||
|
# This gives a quick-glance shape without the tallest bar crushing small ones.
|
||||||
|
sqrt_denom = math.sqrt(max_count) if max_count > 0 else 1.0
|
||||||
|
for b in raw_bins:
|
||||||
|
b["pct"] = round(math.sqrt(b["count"]) * 100 / sqrt_denom)
|
||||||
|
|
||||||
|
return raw_bins
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Type aliases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Replacement record: {original, replacement, original_price, replacement_price, price_diff}
|
||||||
|
Replacement = Dict[str, Any]
|
||||||
|
# Pickup record: {card, price, tier, priority, tags}
|
||||||
|
Pickup = Dict[str, Any]
|
||||||
|
# BudgetReport schema (see class docstring for full spec)
|
||||||
|
BudgetReport = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetEvaluatorService(BaseService):
|
||||||
|
"""Evaluate a deck list against a budget and suggest replacements.
|
||||||
|
|
||||||
|
Requires access to a ``PriceService`` for price lookups and a card index
|
||||||
|
for tag-based alternative discovery (loaded lazily from the Parquet file).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
svc = BudgetEvaluatorService()
|
||||||
|
report = svc.evaluate_deck(
|
||||||
|
decklist=["Sol Ring", "Mana Crypt", ...],
|
||||||
|
budget_total=150.0,
|
||||||
|
mode="soft",
|
||||||
|
include_cards=["Mana Crypt"], # exempt from replacement
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, price_service: Optional[PriceService] = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._price_svc = price_service or get_price_service()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def evaluate_deck(
|
||||||
|
self,
|
||||||
|
decklist: List[str],
|
||||||
|
budget_total: float,
|
||||||
|
mode: str = "soft",
|
||||||
|
*,
|
||||||
|
card_ceiling: Optional[float] = None,
|
||||||
|
region: str = "usd",
|
||||||
|
foil: bool = False,
|
||||||
|
include_cards: Optional[List[str]] = None,
|
||||||
|
color_identity: Optional[List[str]] = None,
|
||||||
|
legacy_fail_open: bool = True,
|
||||||
|
) -> BudgetReport:
|
||||||
|
"""Evaluate deck cost versus budget and produce a BudgetReport.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decklist: Card names in the deck (one entry per card slot, no
|
||||||
|
duplicates for normal cards; include the same name once per slot
|
||||||
|
for multi-copy cards).
|
||||||
|
budget_total: Maximum total deck cost in USD (or EUR if ``region``
|
||||||
|
is ``"eur"``).
|
||||||
|
mode: ``"soft"`` — advisory only; ``"hard"`` — flags budget
|
||||||
|
violations but does not auto-replace.
|
||||||
|
card_ceiling: Optional per-card price cap. Cards priced above this
|
||||||
|
are flagged independently of the total.
|
||||||
|
region: Price region — ``"usd"`` (default) or ``"eur"``.
|
||||||
|
foil: If ``True``, compare foil prices.
|
||||||
|
include_cards: Cards exempt from budget enforcement (never
|
||||||
|
auto-flagged for replacement).
|
||||||
|
color_identity: Commander color identity letters, e.g. ``["U","B"]``.
|
||||||
|
Used to filter alternatives so they remain legal.
|
||||||
|
legacy_fail_open: If ``True`` (default), cards with no price data
|
||||||
|
are skipped in the budget calculation rather than causing an
|
||||||
|
error.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``BudgetReport`` dict with the following keys:
|
||||||
|
|
||||||
|
- ``total_price`` — sum of all prices found
|
||||||
|
- ``budget_status`` — ``"under"`` | ``"soft_exceeded"`` |
|
||||||
|
``"hard_exceeded"``
|
||||||
|
- ``overage`` — amount over budget (0 if under)
|
||||||
|
- ``include_budget_overage`` — cost from include-list cards
|
||||||
|
- ``over_budget_cards`` — list of {card, price, ceiling_exceeded}
|
||||||
|
for cards above ceiling or contributing most to overage
|
||||||
|
- ``price_breakdown`` — per-card {card, price, is_include,
|
||||||
|
ceiling_exceeded, stale}
|
||||||
|
- ``stale_prices`` — cards whose price data may be outdated
|
||||||
|
- ``pickups_list`` — priority-sorted acquisition suggestions
|
||||||
|
- ``replacements_available`` — alternative cards for over-budget
|
||||||
|
slots (excludes include-list cards)
|
||||||
|
"""
|
||||||
|
self._validate(budget_total >= 0, "budget_total must be non-negative")
|
||||||
|
self._validate(mode in ("soft", "hard"), "mode must be 'soft' or 'hard'")
|
||||||
|
if card_ceiling is not None:
|
||||||
|
self._validate(card_ceiling >= 0, "card_ceiling must be non-negative")
|
||||||
|
|
||||||
|
include_set: Set[str] = {c.lower().strip() for c in (include_cards or [])}
|
||||||
|
names = [n.strip() for n in decklist if n.strip()]
|
||||||
|
prices = self._price_svc.get_prices_batch(names, region=region, foil=foil)
|
||||||
|
|
||||||
|
total_price = 0.0
|
||||||
|
include_overage = 0.0
|
||||||
|
breakdown: List[Dict[str, Any]] = []
|
||||||
|
over_budget_cards: List[Dict[str, Any]] = []
|
||||||
|
stale: List[str] = []
|
||||||
|
|
||||||
|
for card in names:
|
||||||
|
price = prices.get(card)
|
||||||
|
is_include = card.lower().strip() in include_set
|
||||||
|
|
||||||
|
if price is None:
|
||||||
|
if not legacy_fail_open:
|
||||||
|
raise ValueError(f"No price data for '{card}' and legacy_fail_open=False")
|
||||||
|
stale.append(card)
|
||||||
|
price_used = 0.0
|
||||||
|
else:
|
||||||
|
price_used = price
|
||||||
|
|
||||||
|
ceil_exceeded = card_ceiling is not None and price_used > card_ceiling
|
||||||
|
total_price += price_used
|
||||||
|
|
||||||
|
if is_include:
|
||||||
|
include_overage += price_used
|
||||||
|
|
||||||
|
breakdown.append({
|
||||||
|
"card": card,
|
||||||
|
"price": price_used if price is not None else None,
|
||||||
|
"is_include": is_include,
|
||||||
|
"ceiling_exceeded": ceil_exceeded,
|
||||||
|
})
|
||||||
|
|
||||||
|
if ceil_exceeded and not is_include:
|
||||||
|
over_budget_cards.append({
|
||||||
|
"card": card,
|
||||||
|
"price": price_used,
|
||||||
|
"ceiling_exceeded": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
overage = max(0.0, total_price - budget_total)
|
||||||
|
|
||||||
|
if overage == 0.0:
|
||||||
|
status = "under"
|
||||||
|
elif mode == "hard":
|
||||||
|
status = "hard_exceeded"
|
||||||
|
else:
|
||||||
|
status = "soft_exceeded"
|
||||||
|
|
||||||
|
# Compute replacements for over-budget / ceiling-exceeded cards
|
||||||
|
replacements = self._find_replacements(
|
||||||
|
over_budget_cards=over_budget_cards,
|
||||||
|
all_prices=prices,
|
||||||
|
include_set=include_set,
|
||||||
|
card_ceiling=card_ceiling,
|
||||||
|
region=region,
|
||||||
|
foil=foil,
|
||||||
|
color_identity=color_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build pickups list from cards not in deck, sorted by priority tier
|
||||||
|
pickups = self._build_pickups_list(
|
||||||
|
decklist=names,
|
||||||
|
region=region,
|
||||||
|
foil=foil,
|
||||||
|
budget_remaining=max(0.0, budget_total - total_price),
|
||||||
|
color_identity=color_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_price": round(total_price, 2),
|
||||||
|
"budget_status": status,
|
||||||
|
"overage": round(overage, 2),
|
||||||
|
"include_budget_overage": round(include_overage, 2),
|
||||||
|
"over_budget_cards": over_budget_cards,
|
||||||
|
"price_breakdown": breakdown,
|
||||||
|
"stale_prices": stale,
|
||||||
|
"pickups_list": pickups,
|
||||||
|
"replacements_available": replacements,
|
||||||
|
}
|
||||||
|
|
||||||
|
def find_cheaper_alternatives(
|
||||||
|
self,
|
||||||
|
card_name: str,
|
||||||
|
max_price: float,
|
||||||
|
*,
|
||||||
|
region: str = "usd",
|
||||||
|
foil: bool = False,
|
||||||
|
color_identity: Optional[List[str]] = None,
|
||||||
|
tags: Optional[List[str]] = None,
|
||||||
|
require_type: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Find cards that share tags with *card_name* and cost ≤ *max_price*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_name: The card to find alternatives for.
|
||||||
|
max_price: Maximum price for alternatives in the given region.
|
||||||
|
region: Price region.
|
||||||
|
foil: If ``True``, compare foil prices.
|
||||||
|
color_identity: If given, filter to cards legal in this identity.
|
||||||
|
tags: Tags to use for matching (skips lookup if provided).
|
||||||
|
require_type: If given, only return cards whose type_line contains
|
||||||
|
this string (e.g. "Land", "Creature"). Auto-detected from the
|
||||||
|
card index when not provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts ``{name, price, tags, shared_tags}`` sorted by most
|
||||||
|
shared tags descending then price ascending, capped at
|
||||||
|
``_MAX_ALTERNATIVES``.
|
||||||
|
"""
|
||||||
|
lookup_tags = tags or self._get_card_tags(card_name)
|
||||||
|
if not lookup_tags:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Determine the broad card type for like-for-like filtering.
|
||||||
|
source_type = require_type or self._get_card_broad_type(card_name)
|
||||||
|
|
||||||
|
candidates: Dict[str, Dict[str, Any]] = {} # name → candidate dict
|
||||||
|
|
||||||
|
try:
|
||||||
|
from code.web.services.card_index import get_tag_pool, maybe_build_index
|
||||||
|
maybe_build_index()
|
||||||
|
|
||||||
|
ci_set: Optional[Set[str]] = (
|
||||||
|
{c.upper() for c in color_identity} if color_identity else None
|
||||||
|
)
|
||||||
|
|
||||||
|
for tag in lookup_tags:
|
||||||
|
for card in get_tag_pool(tag):
|
||||||
|
name = card.get("name", "")
|
||||||
|
if not name or name.lower() == card_name.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Like-for-like type filter (Land→Land, Creature→Creature, etc.)
|
||||||
|
if source_type:
|
||||||
|
type_line = card.get("type_line", "")
|
||||||
|
if source_type not in type_line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Color identity check
|
||||||
|
if ci_set is not None:
|
||||||
|
card_colors = set(card.get("color_identity_list", []))
|
||||||
|
if card_colors and not card_colors.issubset(ci_set):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name not in candidates:
|
||||||
|
candidates[name] = {
|
||||||
|
"name": name,
|
||||||
|
"tags": card.get("tags", []),
|
||||||
|
"shared_tags": set(),
|
||||||
|
}
|
||||||
|
candidates[name]["shared_tags"].add(tag)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Card index unavailable for alternatives: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Batch price lookup for all candidates
|
||||||
|
candidate_names = list(candidates.keys())
|
||||||
|
prices = self._price_svc.get_prices_batch(candidate_names, region=region, foil=foil)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name, info in candidates.items():
|
||||||
|
price = prices.get(name)
|
||||||
|
if price is None or price > max_price:
|
||||||
|
continue
|
||||||
|
results.append({
|
||||||
|
"name": name,
|
||||||
|
"price": round(price, 2),
|
||||||
|
"tags": info["tags"],
|
||||||
|
"shared_tags": sorted(info["shared_tags"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by most shared tags first (most role-matched), then price ascending.
|
||||||
|
results.sort(key=lambda x: (-len(x["shared_tags"]), x["price"]))
|
||||||
|
return results[:_MAX_ALTERNATIVES]
|
||||||
|
|
||||||
|
def calculate_tier_ceilings(self, total_budget: float) -> Dict[str, float]:
|
||||||
|
"""Compute splurge tier price ceilings from *total_budget*.
|
||||||
|
|
||||||
|
S-tier = up to 20 % of budget per card slot
|
||||||
|
M-tier = up to 10 %
|
||||||
|
L-tier = up to 5 %
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_budget: Total deck budget.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict ``{"S": float, "M": float, "L": float}``.
|
||||||
|
"""
|
||||||
|
return {tier: round(total_budget * frac, 2) for tier, frac in _TIER_FRACTIONS.items()}
|
||||||
|
|
||||||
|
def generate_pickups_list(
|
||||||
|
self,
|
||||||
|
decklist: List[str],
|
||||||
|
budget_remaining: float,
|
||||||
|
*,
|
||||||
|
region: str = "usd",
|
||||||
|
foil: bool = False,
|
||||||
|
color_identity: Optional[List[str]] = None,
|
||||||
|
) -> List[Pickup]:
|
||||||
|
"""Generate a prioritized acquisition list of cards not in *decklist*.
|
||||||
|
|
||||||
|
Finds cards that fit the color identity, share tags with the current
|
||||||
|
deck, and cost ≤ *budget_remaining*, sorted by number of shared tags
|
||||||
|
(most synergistic first).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decklist: Current deck card names.
|
||||||
|
budget_remaining: Maximum price per card to include.
|
||||||
|
region: Price region.
|
||||||
|
foil: If ``True``, use foil prices.
|
||||||
|
color_identity: Commander color identity for legality filter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of pickup dicts sorted by synergy score (shared tags count).
|
||||||
|
"""
|
||||||
|
return self._build_pickups_list(
|
||||||
|
decklist=decklist,
|
||||||
|
region=region,
|
||||||
|
foil=foil,
|
||||||
|
budget_remaining=budget_remaining,
|
||||||
|
color_identity=color_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_card_tags(self, card_name: str) -> List[str]:
|
||||||
|
"""Look up theme tags for a single card from the card index."""
|
||||||
|
try:
|
||||||
|
from code.web.services.card_index import maybe_build_index, _CARD_INDEX
|
||||||
|
maybe_build_index()
|
||||||
|
needle = card_name.lower()
|
||||||
|
for cards in _CARD_INDEX.values():
|
||||||
|
for c in cards:
|
||||||
|
if c.get("name", "").lower() == needle:
|
||||||
|
return list(c.get("tags", []))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _get_card_broad_type(self, card_name: str) -> Optional[str]:
|
||||||
|
"""Return the first matching broad MTG type for a card (e.g. 'Land', 'Creature')."""
|
||||||
|
try:
|
||||||
|
from code.web.services.card_index import maybe_build_index, _CARD_INDEX
|
||||||
|
maybe_build_index()
|
||||||
|
needle = card_name.lower()
|
||||||
|
for cards in _CARD_INDEX.values():
|
||||||
|
for c in cards:
|
||||||
|
if c.get("name", "").lower() == needle:
|
||||||
|
type_line = c.get("type_line", "")
|
||||||
|
for broad in _BROAD_TYPES:
|
||||||
|
if broad in type_line:
|
||||||
|
return broad
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_replacements(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
over_budget_cards: List[Dict[str, Any]],
|
||||||
|
all_prices: Dict[str, Optional[float]],
|
||||||
|
include_set: Set[str],
|
||||||
|
card_ceiling: Optional[float],
|
||||||
|
region: str,
|
||||||
|
foil: bool,
|
||||||
|
color_identity: Optional[List[str]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Find cheaper alternatives for over-budget (non-include) cards."""
|
||||||
|
results = []
|
||||||
|
for entry in over_budget_cards:
|
||||||
|
card = entry["card"]
|
||||||
|
price = entry["price"]
|
||||||
|
if card.lower().strip() in include_set:
|
||||||
|
continue
|
||||||
|
max_alt_price = (card_ceiling - 0.01) if card_ceiling else max(0.0, price - 0.01)
|
||||||
|
alts = self.find_cheaper_alternatives(
|
||||||
|
card,
|
||||||
|
max_price=max_alt_price,
|
||||||
|
region=region,
|
||||||
|
foil=foil,
|
||||||
|
color_identity=color_identity,
|
||||||
|
)
|
||||||
|
if alts:
|
||||||
|
results.append({
|
||||||
|
"original": card,
|
||||||
|
"original_price": price,
|
||||||
|
"alternatives": alts,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _build_pickups_list(
|
||||||
|
self,
|
||||||
|
decklist: List[str],
|
||||||
|
region: str,
|
||||||
|
foil: bool,
|
||||||
|
budget_remaining: float,
|
||||||
|
color_identity: Optional[List[str]],
|
||||||
|
) -> List[Pickup]:
|
||||||
|
"""Build a ranked pickups list using shared-tag scoring."""
|
||||||
|
if budget_remaining <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
deck_set = {n.lower() for n in decklist}
|
||||||
|
|
||||||
|
# Collect all unique tags from the current deck
|
||||||
|
deck_tags: Set[str] = set()
|
||||||
|
try:
|
||||||
|
from code.web.services.card_index import maybe_build_index, _CARD_INDEX
|
||||||
|
maybe_build_index()
|
||||||
|
|
||||||
|
for name in decklist:
|
||||||
|
needle = name.lower()
|
||||||
|
for cards in _CARD_INDEX.values():
|
||||||
|
for c in cards:
|
||||||
|
if c.get("name", "").lower() == needle:
|
||||||
|
deck_tags.update(c.get("tags", []))
|
||||||
|
break
|
||||||
|
|
||||||
|
if not deck_tags:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ci_set: Optional[Set[str]] = (
|
||||||
|
{c.upper() for c in color_identity} if color_identity else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Score candidate cards not in deck by shared tags
|
||||||
|
candidates: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for tag in deck_tags:
|
||||||
|
for card in _CARD_INDEX.get(tag, []):
|
||||||
|
name = card.get("name", "")
|
||||||
|
if not name or name.lower() in deck_set:
|
||||||
|
continue
|
||||||
|
if ci_set:
|
||||||
|
card_colors = set(card.get("color_identity_list", []))
|
||||||
|
if card_colors and not card_colors.issubset(ci_set):
|
||||||
|
continue
|
||||||
|
if name not in candidates:
|
||||||
|
candidates[name] = {"name": name, "tags": card.get("tags", []), "score": 0}
|
||||||
|
candidates[name]["score"] += 1
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not build pickups list: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Price filter
|
||||||
|
top_candidates = sorted(candidates.values(), key=lambda x: x["score"], reverse=True)[:200]
|
||||||
|
names = [c["name"] for c in top_candidates]
|
||||||
|
prices = self._price_svc.get_prices_batch(names, region=region, foil=foil)
|
||||||
|
|
||||||
|
tier_ceilings = self.calculate_tier_ceilings(budget_remaining)
|
||||||
|
pickups: List[Pickup] = []
|
||||||
|
|
||||||
|
for c in top_candidates:
|
||||||
|
price = prices.get(c["name"])
|
||||||
|
if price is None or price > budget_remaining:
|
||||||
|
continue
|
||||||
|
tier = "L"
|
||||||
|
if price <= tier_ceilings["L"]:
|
||||||
|
tier = "L"
|
||||||
|
if price <= tier_ceilings["M"]:
|
||||||
|
tier = "M"
|
||||||
|
if price <= tier_ceilings["S"]:
|
||||||
|
tier = "S"
|
||||||
|
pickups.append({
|
||||||
|
"card": c["name"],
|
||||||
|
"price": round(price, 2),
|
||||||
|
"tier": tier,
|
||||||
|
"priority": c["score"],
|
||||||
|
"tags": c["tags"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort: most synergistic first, then cheapest
|
||||||
|
pickups.sort(key=lambda x: (-x["priority"], x["price"]))
|
||||||
|
return pickups[:50]
|
||||||
|
|
@ -108,6 +108,26 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
|
||||||
"allow_illegal": bool(sess.get("allow_illegal")),
|
"allow_illegal": bool(sess.get("allow_illegal")),
|
||||||
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
|
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
|
||||||
}
|
}
|
||||||
|
ctx["budget_config"] = sess.get("budget_config") or {}
|
||||||
|
ctx["build_id"] = str(sess.get("build_id") or "0")
|
||||||
|
try:
|
||||||
|
from ..services.price_service import get_price_service
|
||||||
|
from code.settings import PRICE_STALE_WARNING_HOURS
|
||||||
|
_svc = get_price_service()
|
||||||
|
_svc._ensure_loaded()
|
||||||
|
ctx["price_cache"] = _svc._cache # keyed by lowercase card name → {usd, usd_foil, ...}
|
||||||
|
_stale = _svc.get_stale_cards(PRICE_STALE_WARNING_HOURS) if PRICE_STALE_WARNING_HOURS > 0 else set()
|
||||||
|
# Suppress per-card noise when >50% of the priced pool is stale
|
||||||
|
if _stale and len(_stale) > len(_svc._cache) * 0.5:
|
||||||
|
ctx["stale_prices"] = set()
|
||||||
|
ctx["stale_prices_global"] = True
|
||||||
|
else:
|
||||||
|
ctx["stale_prices"] = _stale
|
||||||
|
ctx["stale_prices_global"] = False
|
||||||
|
except Exception:
|
||||||
|
ctx["price_cache"] = {}
|
||||||
|
ctx["stale_prices"] = set()
|
||||||
|
ctx["stale_prices_global"] = False
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -168,6 +188,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
|
||||||
partner_feature_enabled=partner_enabled,
|
partner_feature_enabled=partner_enabled,
|
||||||
secondary_commander=secondary_commander,
|
secondary_commander=secondary_commander,
|
||||||
background_commander=background_choice,
|
background_commander=background_choice,
|
||||||
|
budget_config=sess.get("budget_config"),
|
||||||
)
|
)
|
||||||
if set_on_session:
|
if set_on_session:
|
||||||
sess["build_ctx"] = ctx
|
sess["build_ctx"] = ctx
|
||||||
|
|
@ -523,9 +544,166 @@ def step5_ctx_from_result(
|
||||||
ctx["summary_token"] = token_val
|
ctx["summary_token"] = token_val
|
||||||
ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
|
ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
|
||||||
ctx["synergies"] = synergies_list
|
ctx["synergies"] = synergies_list
|
||||||
|
|
||||||
|
# M5: Post-build budget review (only when build is done and budget mode active)
|
||||||
|
if done:
|
||||||
|
try:
|
||||||
|
_apply_budget_review_ctx(sess, res, ctx)
|
||||||
|
except Exception:
|
||||||
|
ctx.setdefault("over_budget_review", False)
|
||||||
|
ctx.setdefault("budget_review_visible", False)
|
||||||
|
ctx.setdefault("price_category_chart", None)
|
||||||
|
ctx.setdefault("price_histogram_chart", None)
|
||||||
|
else:
|
||||||
|
ctx.setdefault("over_budget_review", False)
|
||||||
|
ctx.setdefault("budget_review_visible", False)
|
||||||
|
ctx.setdefault("price_category_chart", None)
|
||||||
|
ctx.setdefault("price_histogram_chart", None)
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_budget_review_ctx(sess: dict, res: dict, ctx: dict) -> None:
|
||||||
|
"""M5: Compute end-of-build budget review data and inject into ctx.
|
||||||
|
|
||||||
|
Triggers when total deck cost exceeds budget_total by more than BUDGET_TOTAL_TOLERANCE.
|
||||||
|
Shows the most expensive non-include cards (contributors to total overage) with
|
||||||
|
cheaper alternatives drawn from find_cheaper_alternatives().
|
||||||
|
"""
|
||||||
|
budget_cfg = sess.get("budget_config") or {}
|
||||||
|
try:
|
||||||
|
budget_total = float(budget_cfg.get("total") or 0)
|
||||||
|
except Exception:
|
||||||
|
budget_total = 0.0
|
||||||
|
if budget_total <= 0:
|
||||||
|
ctx["over_budget_review"] = False
|
||||||
|
return
|
||||||
|
|
||||||
|
budget_mode = "soft"
|
||||||
|
try:
|
||||||
|
card_ceiling = float(budget_cfg.get("card_ceiling")) if budget_cfg.get("card_ceiling") else None
|
||||||
|
except Exception:
|
||||||
|
card_ceiling = None
|
||||||
|
include_cards = [str(c).strip() for c in (sess.get("include_cards") or []) if str(c).strip()]
|
||||||
|
|
||||||
|
# Extract card names from build summary + build name -> {type, role, tags} lookup
|
||||||
|
summary = res.get("summary") or {}
|
||||||
|
card_names: list[str] = []
|
||||||
|
card_meta: dict[str, dict] = {}
|
||||||
|
if isinstance(summary, dict):
|
||||||
|
tb = summary.get("type_breakdown") or {}
|
||||||
|
for type_key, type_cards_list in (tb.get("cards") or {}).items():
|
||||||
|
for c in type_cards_list:
|
||||||
|
name = c.get("name") if isinstance(c, dict) else None
|
||||||
|
if name:
|
||||||
|
sname = str(name).strip()
|
||||||
|
card_names.append(sname)
|
||||||
|
card_meta[sname] = {
|
||||||
|
"type": type_key,
|
||||||
|
"role": str(c.get("role") or "").strip(),
|
||||||
|
"tags": list(c.get("tags") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not card_names:
|
||||||
|
ctx["over_budget_review"] = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# Persist snapshot for the swap route
|
||||||
|
sess["budget_deck_snapshot"] = list(card_names)
|
||||||
|
|
||||||
|
color_identity: list[str] | None = None
|
||||||
|
try:
|
||||||
|
ci_raw = sess.get("color_identity")
|
||||||
|
if ci_raw and isinstance(ci_raw, list):
|
||||||
|
color_identity = [str(c).upper() for c in ci_raw]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from ..services.budget_evaluator import BudgetEvaluatorService
|
||||||
|
svc = BudgetEvaluatorService()
|
||||||
|
report = svc.evaluate_deck(
|
||||||
|
card_names,
|
||||||
|
budget_total,
|
||||||
|
mode=budget_mode,
|
||||||
|
card_ceiling=card_ceiling,
|
||||||
|
include_cards=include_cards,
|
||||||
|
color_identity=color_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_price = float(report.get("total_price", 0.0))
|
||||||
|
tolerance = bc.BUDGET_TOTAL_TOLERANCE
|
||||||
|
over_budget_review = total_price > budget_total * (1.0 + tolerance)
|
||||||
|
|
||||||
|
ctx["budget_review_visible"] = over_budget_review # only shown when deck total exceeds tolerance
|
||||||
|
ctx["over_budget_review"] = over_budget_review
|
||||||
|
ctx["budget_review_total"] = round(total_price, 2)
|
||||||
|
ctx["budget_review_cap"] = round(budget_total, 2)
|
||||||
|
ctx["budget_overage_pct"] = 0.0
|
||||||
|
ctx["over_budget_cards"] = []
|
||||||
|
|
||||||
|
overage = total_price - budget_total
|
||||||
|
if over_budget_review:
|
||||||
|
ctx["budget_overage_pct"] = round(overage / budget_total * 100, 1)
|
||||||
|
|
||||||
|
include_set = {c.lower().strip() for c in include_cards}
|
||||||
|
|
||||||
|
# Use price_breakdown sorted by price desc — most expensive cards are the biggest
|
||||||
|
# contributors to the total overage regardless of any per-card ceiling.
|
||||||
|
breakdown = report.get("price_breakdown") or []
|
||||||
|
priced = sorted(
|
||||||
|
[e for e in breakdown
|
||||||
|
if not e.get("is_include") and (e.get("price") is not None) and float(e.get("price") or 0.0) > 0],
|
||||||
|
key=lambda x: -float(x.get("price") or 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
over_cards_out: list[dict] = []
|
||||||
|
for entry in priced[:6]:
|
||||||
|
name = entry.get("card", "")
|
||||||
|
price = float(entry.get("price") or 0.0)
|
||||||
|
is_include = name.lower().strip() in include_set
|
||||||
|
meta = card_meta.get(name, {})
|
||||||
|
try:
|
||||||
|
# Any cheaper alternative reduces the total; use price - 0.01 as the ceiling
|
||||||
|
alts_raw = svc.find_cheaper_alternatives(
|
||||||
|
name,
|
||||||
|
max_price=max(0.0, price - 0.01),
|
||||||
|
region="usd",
|
||||||
|
color_identity=color_identity,
|
||||||
|
tags=meta.get("tags") or None,
|
||||||
|
require_type=meta.get("type") or None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
alts_raw = []
|
||||||
|
over_cards_out.append({
|
||||||
|
"name": name,
|
||||||
|
"price": price,
|
||||||
|
"swap_disabled": is_include,
|
||||||
|
"card_type": meta.get("type", ""),
|
||||||
|
"card_role": meta.get("role", ""),
|
||||||
|
"card_tags": meta.get("tags", []),
|
||||||
|
"alternatives": [
|
||||||
|
{"name": a["name"], "price": a.get("price"), "shared_tags": a.get("shared_tags", [])}
|
||||||
|
for a in alts_raw[:3]
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx["over_budget_cards"] = over_cards_out
|
||||||
|
|
||||||
|
# M8: Price charts — category breakdown + histogram
|
||||||
|
try:
|
||||||
|
from ..services.budget_evaluator import compute_price_category_breakdown, compute_price_histogram
|
||||||
|
_breakdown = report.get("price_breakdown") or []
|
||||||
|
_enriched = [
|
||||||
|
{**item, "tags": card_meta.get(item.get("card", ""), {}).get("tags", [])}
|
||||||
|
for item in _breakdown
|
||||||
|
]
|
||||||
|
ctx["price_category_chart"] = compute_price_category_breakdown(_enriched)
|
||||||
|
ctx["price_histogram_chart"] = compute_price_histogram(_breakdown)
|
||||||
|
except Exception:
|
||||||
|
ctx.setdefault("price_category_chart", None)
|
||||||
|
ctx.setdefault("price_histogram_chart", None)
|
||||||
|
|
||||||
|
|
||||||
def step5_error_ctx(
|
def step5_error_ctx(
|
||||||
request: Request,
|
request: Request,
|
||||||
sess: dict,
|
sess: dict,
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ def maybe_build_index() -> None:
|
||||||
color_id = str(row.get(COLOR_IDENTITY_COL) or "").strip()
|
color_id = str(row.get(COLOR_IDENTITY_COL) or "").strip()
|
||||||
mana_cost = str(row.get(MANA_COST_COL) or "").strip()
|
mana_cost = str(row.get(MANA_COST_COL) or "").strip()
|
||||||
rarity = _normalize_rarity(str(row.get(RARITY_COL) or ""))
|
rarity = _normalize_rarity(str(row.get(RARITY_COL) or ""))
|
||||||
|
type_line = str(row.get("type") or row.get("type_line") or "").strip()
|
||||||
|
|
||||||
for tg in tags:
|
for tg in tags:
|
||||||
if not tg:
|
if not tg:
|
||||||
|
|
@ -92,6 +93,7 @@ def maybe_build_index() -> None:
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"mana_cost": mana_cost,
|
"mana_cost": mana_cost,
|
||||||
"rarity": rarity,
|
"rarity": rarity,
|
||||||
|
"type_line": type_line,
|
||||||
"color_identity_list": [c.strip() for c in color_id.split(',') if c.strip()],
|
"color_identity_list": [c.strip() for c in color_id.split(',') if c.strip()],
|
||||||
"pip_colors": [c for c in mana_cost if c in {"W","U","B","R","G"}],
|
"pip_colors": [c for c in mana_cost if c in {"W","U","B","R","G"}],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2037,6 +2037,9 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
||||||
custom_base = None
|
custom_base = None
|
||||||
if isinstance(custom_base, str) and custom_base.strip():
|
if isinstance(custom_base, str) and custom_base.strip():
|
||||||
meta["name"] = custom_base.strip()
|
meta["name"] = custom_base.strip()
|
||||||
|
budget_cfg = getattr(b, 'budget_config', None)
|
||||||
|
if isinstance(budget_cfg, dict) and budget_cfg.get('total'):
|
||||||
|
meta['budget_config'] = budget_cfg
|
||||||
payload = {"meta": meta, "summary": summary}
|
payload = {"meta": meta, "summary": summary}
|
||||||
with open(sidecar, 'w', encoding='utf-8') as f:
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||||
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
|
@ -2516,6 +2519,7 @@ def start_build_ctx(
|
||||||
partner_feature_enabled: bool | None = None,
|
partner_feature_enabled: bool | None = None,
|
||||||
secondary_commander: str | None = None,
|
secondary_commander: str | None = None,
|
||||||
background_commander: str | None = None,
|
background_commander: str | None = None,
|
||||||
|
budget_config: Dict[str, Any] | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
logs: List[str] = []
|
logs: List[str] = []
|
||||||
|
|
||||||
|
|
@ -2667,6 +2671,15 @@ def start_build_ctx(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Stages
|
# Stages
|
||||||
|
try:
|
||||||
|
b.budget_config = dict(budget_config) if isinstance(budget_config, dict) else None
|
||||||
|
except Exception:
|
||||||
|
b.budget_config = None
|
||||||
|
# M4: Apply budget pool filter now that budget_config is set
|
||||||
|
try:
|
||||||
|
b.apply_budget_pool_filter()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
stages = _make_stages(b)
|
stages = _make_stages(b)
|
||||||
ctx = {
|
ctx = {
|
||||||
"builder": b,
|
"builder": b,
|
||||||
|
|
@ -2867,6 +2880,9 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
custom_base = None
|
custom_base = None
|
||||||
if isinstance(custom_base, str) and custom_base.strip():
|
if isinstance(custom_base, str) and custom_base.strip():
|
||||||
meta["name"] = custom_base.strip()
|
meta["name"] = custom_base.strip()
|
||||||
|
budget_cfg = getattr(b, 'budget_config', None)
|
||||||
|
if isinstance(budget_cfg, dict) and budget_cfg.get('total'):
|
||||||
|
meta['budget_config'] = budget_cfg
|
||||||
payload = {"meta": meta, "summary": summary}
|
payload = {"meta": meta, "summary": summary}
|
||||||
with open(sidecar, 'w', encoding='utf-8') as f:
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||||
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
|
@ -3718,6 +3734,9 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
custom_base = None
|
custom_base = None
|
||||||
if isinstance(custom_base, str) and custom_base.strip():
|
if isinstance(custom_base, str) and custom_base.strip():
|
||||||
meta["name"] = custom_base.strip()
|
meta["name"] = custom_base.strip()
|
||||||
|
budget_cfg = getattr(b, 'budget_config', None)
|
||||||
|
if isinstance(budget_cfg, dict) and budget_cfg.get('total'):
|
||||||
|
meta['budget_config'] = budget_cfg
|
||||||
payload = {"meta": meta, "summary": summary}
|
payload = {"meta": meta, "summary": summary}
|
||||||
with open(sidecar, 'w', encoding='utf-8') as f:
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||||
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
|
|
||||||
504
code/web/services/price_service.py
Normal file
504
code/web/services/price_service.py
Normal file
|
|
@ -0,0 +1,504 @@
|
||||||
|
"""Price service for card price lookups.
|
||||||
|
|
||||||
|
Loads prices from the local Scryfall bulk data file (one card per line),
|
||||||
|
caches results in a compact JSON file under card_files/, and provides
|
||||||
|
thread-safe batch lookups for budget evaluation.
|
||||||
|
|
||||||
|
Cache strategy:
|
||||||
|
- On first access, load from prices_cache.json if < TTL hours old
|
||||||
|
- If cache is stale or missing, rebuild by streaming the bulk data file
|
||||||
|
- In-memory dict (normalized lowercase key) is kept for fast lookups
|
||||||
|
- Background refresh available via refresh_cache_background()
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from code.path_util import card_files_dir, card_files_raw_dir
|
||||||
|
from code.web.services.base import BaseService
|
||||||
|
from code import logging_util
|
||||||
|
|
||||||
|
logger = logging_util.logging.getLogger(__name__)
|
||||||
|
logger.setLevel(logging_util.LOG_LEVEL)
|
||||||
|
logger.addHandler(logging_util.file_handler)
|
||||||
|
logger.addHandler(logging_util.stream_handler)
|
||||||
|
|
||||||
|
_CACHE_TTL_SECONDS = 86400 # 24 hours
|
||||||
|
_BULK_DATA_FILENAME = "scryfall_bulk_data.json"
|
||||||
|
_PRICE_CACHE_FILENAME = "prices_cache.json"
|
||||||
|
|
||||||
|
|
||||||
|
class PriceService(BaseService):
|
||||||
|
"""Service for card price lookups backed by Scryfall bulk data.
|
||||||
|
|
||||||
|
Reads prices from the local Scryfall bulk data file that the setup
|
||||||
|
pipeline already downloads. A compact JSON cache is written to
|
||||||
|
card_files/ so subsequent startups load instantly without re-scanning
|
||||||
|
the 500 MB bulk file.
|
||||||
|
|
||||||
|
All public methods are thread-safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bulk_data_path: Optional[str] = None,
|
||||||
|
cache_path: Optional[str] = None,
|
||||||
|
cache_ttl: int = _CACHE_TTL_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._bulk_path: str = bulk_data_path or os.path.join(
|
||||||
|
card_files_raw_dir(), _BULK_DATA_FILENAME
|
||||||
|
)
|
||||||
|
self._cache_path: str = cache_path or os.path.join(
|
||||||
|
card_files_dir(), _PRICE_CACHE_FILENAME
|
||||||
|
)
|
||||||
|
self._ttl: int = cache_ttl
|
||||||
|
|
||||||
|
# {normalized_card_name: {"usd": float, "usd_foil": float, "eur": float, "eur_foil": float}}
|
||||||
|
self._cache: Dict[str, Dict[str, float]] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._loaded = False
|
||||||
|
self._last_refresh: float = 0.0
|
||||||
|
self._hit_count = 0
|
||||||
|
self._miss_count = 0
|
||||||
|
self._refresh_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_price(
|
||||||
|
self,
|
||||||
|
card_name: str,
|
||||||
|
region: str = "usd",
|
||||||
|
foil: bool = False,
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""Return the price for *card_name* or ``None`` if not found.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_name: Card name (case-insensitive).
|
||||||
|
region: Price region - ``"usd"`` or ``"eur"``.
|
||||||
|
foil: If ``True`` return foil price.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Price as float or ``None`` when missing / card unknown.
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
price_key = region + ("_foil" if foil else "")
|
||||||
|
entry = self._cache.get(card_name.lower().strip())
|
||||||
|
self.queue_lazy_refresh(card_name)
|
||||||
|
with self._lock:
|
||||||
|
if entry is not None:
|
||||||
|
self._hit_count += 1
|
||||||
|
return entry.get(price_key)
|
||||||
|
self._miss_count += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_prices_batch(
|
||||||
|
self,
|
||||||
|
card_names: List[str],
|
||||||
|
region: str = "usd",
|
||||||
|
foil: bool = False,
|
||||||
|
) -> Dict[str, Optional[float]]:
|
||||||
|
"""Return a mapping of card name → price for all requested cards.
|
||||||
|
|
||||||
|
Missing cards map to ``None``. Preserves input ordering and
|
||||||
|
original case in the returned keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_names: List of card names to look up.
|
||||||
|
region: Price region - ``"usd"`` or ``"eur"``.
|
||||||
|
foil: If ``True`` return foil prices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping each input name to its price (or ``None``).
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
price_key = region + ("_foil" if foil else "")
|
||||||
|
result: Dict[str, Optional[float]] = {}
|
||||||
|
hits = 0
|
||||||
|
misses = 0
|
||||||
|
for name in card_names:
|
||||||
|
entry = self._cache.get(name.lower().strip())
|
||||||
|
if entry is not None:
|
||||||
|
result[name] = entry.get(price_key)
|
||||||
|
hits += 1
|
||||||
|
else:
|
||||||
|
result[name] = None
|
||||||
|
misses += 1
|
||||||
|
with self._lock:
|
||||||
|
self._hit_count += hits
|
||||||
|
self._miss_count += misses
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cache_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Return telemetry snapshot about cache performance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``total_entries``, ``hit_count``, ``miss_count``,
|
||||||
|
``hit_rate``, ``last_refresh``, ``loaded``, ``cache_path``.
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
with self._lock:
|
||||||
|
total = self._hit_count + self._miss_count
|
||||||
|
return {
|
||||||
|
"total_entries": len(self._cache),
|
||||||
|
"hit_count": self._hit_count,
|
||||||
|
"miss_count": self._miss_count,
|
||||||
|
"hit_rate": (self._hit_count / total) if total > 0 else 0.0,
|
||||||
|
"last_refresh": self._last_refresh,
|
||||||
|
"loaded": self._loaded,
|
||||||
|
"cache_path": self._cache_path,
|
||||||
|
"bulk_data_available": os.path.exists(self._bulk_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
def refresh_cache_background(self) -> None:
|
||||||
|
"""Spawn a daemon thread to rebuild the price cache asynchronously.
|
||||||
|
|
||||||
|
If a refresh is already in progress, this call is a no-op.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._refresh_thread and self._refresh_thread.is_alive():
|
||||||
|
logger.debug("Price cache background refresh already running")
|
||||||
|
return
|
||||||
|
t = threading.Thread(
|
||||||
|
target=self._rebuild_cache,
|
||||||
|
daemon=True,
|
||||||
|
name="price-cache-refresh",
|
||||||
|
)
|
||||||
|
self._refresh_thread = t
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def get_cache_built_at(self) -> str | None:
|
||||||
|
"""Return a human-readable price cache build date, or None if unavailable."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._cache_path):
|
||||||
|
import datetime
|
||||||
|
built = os.path.getmtime(self._cache_path)
|
||||||
|
if built:
|
||||||
|
dt = datetime.datetime.fromtimestamp(built, tz=datetime.timezone.utc)
|
||||||
|
return dt.strftime("%B %d, %Y")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def start_daily_refresh(self, hour: int = 1, on_after_rebuild: Optional[Callable] = None) -> None:
|
||||||
|
"""Start a daemon thread that rebuilds prices once daily at *hour* UTC.
|
||||||
|
|
||||||
|
Checks every 30 minutes. Safe to call multiple times — only one
|
||||||
|
scheduler thread will be started.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hour: UTC hour (0–23) at which to run the nightly rebuild.
|
||||||
|
on_after_rebuild: Optional callable invoked after each successful
|
||||||
|
rebuild (e.g., to update the parquet files).
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if getattr(self, "_daily_thread", None) and self._daily_thread.is_alive(): # type: ignore[attr-defined]
|
||||||
|
return
|
||||||
|
|
||||||
|
def _loop() -> None:
|
||||||
|
import datetime
|
||||||
|
last_date: "datetime.date | None" = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||||
|
today = now.date()
|
||||||
|
if now.hour >= hour and today != last_date:
|
||||||
|
logger.info("Scheduled price refresh running (daily at %02d:00 UTC) …", hour)
|
||||||
|
self._rebuild_cache()
|
||||||
|
last_date = today
|
||||||
|
if on_after_rebuild:
|
||||||
|
try:
|
||||||
|
on_after_rebuild()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("on_after_rebuild callback failed: %s", exc)
|
||||||
|
logger.info("Scheduled price refresh complete.")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Daily price refresh error: %s", exc)
|
||||||
|
time.sleep(1800)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_loop, daemon=True, name="price-daily-refresh")
|
||||||
|
self._daily_thread = t # type: ignore[attr-defined]
|
||||||
|
t.start()
|
||||||
|
logger.info("Daily price refresh scheduler started (hour=%d UTC)", hour)
|
||||||
|
|
||||||
|
def start_lazy_refresh(self, stale_days: int = 7) -> None:
|
||||||
|
"""Start a background worker that refreshes per-card prices from the
|
||||||
|
Scryfall API when they have not been updated within *stale_days* days.
|
||||||
|
|
||||||
|
Queuing: call queue_lazy_refresh(card_name) to mark a card as stale.
|
||||||
|
The worker runs every 60 seconds, processes up to 20 cards per cycle,
|
||||||
|
and respects Scryfall's 100 ms rate-limit guideline.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if getattr(self, "_lazy_thread", None) and self._lazy_thread.is_alive(): # type: ignore[attr-defined]
|
||||||
|
return
|
||||||
|
self._lazy_stale_seconds: float = stale_days * 86400
|
||||||
|
self._lazy_queue: set[str] = set()
|
||||||
|
self._lazy_ts: dict[str, float] = self._load_lazy_ts()
|
||||||
|
self._lazy_lock = threading.Lock()
|
||||||
|
|
||||||
|
def _worker() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
time.sleep(60)
|
||||||
|
with self._lazy_lock:
|
||||||
|
batch = list(self._lazy_queue)[:20]
|
||||||
|
self._lazy_queue -= set(batch)
|
||||||
|
if batch:
|
||||||
|
self._fetch_lazy_batch(batch)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Lazy price refresh error: %s", exc)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_worker, daemon=True, name="price-lazy-refresh")
|
||||||
|
self._lazy_thread = t # type: ignore[attr-defined]
|
||||||
|
t.start()
|
||||||
|
logger.info("Lazy price refresh worker started (stale_days=%d)", stale_days)
|
||||||
|
|
||||||
|
def queue_lazy_refresh(self, card_name: str) -> None:
|
||||||
|
"""Mark *card_name* for a lazy per-card price update if its cached
|
||||||
|
price is stale or missing. No-op when lazy mode is not enabled."""
|
||||||
|
if not hasattr(self, "_lazy_queue"):
|
||||||
|
return
|
||||||
|
key = card_name.lower().strip()
|
||||||
|
ts = self._lazy_ts.get(key)
|
||||||
|
if ts is None or (time.time() - ts) > self._lazy_stale_seconds:
|
||||||
|
with self._lazy_lock:
|
||||||
|
self._lazy_queue.add(card_name.strip())
|
||||||
|
|
||||||
|
def _fetch_lazy_batch(self, names: list[str]) -> None:
|
||||||
|
"""Fetch fresh prices for *names* from the Scryfall named-card API."""
|
||||||
|
import urllib.request as _urllib
|
||||||
|
import urllib.parse as _urlparse
|
||||||
|
now = time.time()
|
||||||
|
updated: dict[str, float] = {}
|
||||||
|
for name in names:
|
||||||
|
try:
|
||||||
|
url = "https://api.scryfall.com/cards/named?" + _urlparse.urlencode({"exact": name, "format": "json"})
|
||||||
|
req = _urllib.Request(url, headers={"User-Agent": "MTGPythonDeckbuilder/1.0"})
|
||||||
|
with _urllib.urlopen(req, timeout=5) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
raw_prices: dict = data.get("prices") or {}
|
||||||
|
entry = self._extract_prices(raw_prices)
|
||||||
|
if entry:
|
||||||
|
key = name.lower()
|
||||||
|
with self._lock:
|
||||||
|
self._cache[key] = entry
|
||||||
|
updated[key] = now
|
||||||
|
logger.debug("Lazy refresh: %s → $%.2f", name, entry.get("usd", 0))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Lazy price fetch skipped for %s: %s", name, exc)
|
||||||
|
time.sleep(0.1) # 100 ms — Scryfall rate-limit guideline
|
||||||
|
if updated:
|
||||||
|
self._lazy_ts.update(updated)
|
||||||
|
self._save_lazy_ts()
|
||||||
|
# Also persist the updated in-memory cache to the JSON cache file
|
||||||
|
try:
|
||||||
|
self._persist_cache_snapshot()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _load_lazy_ts(self) -> dict[str, float]:
|
||||||
|
"""Load per-card timestamps from companion file."""
|
||||||
|
ts_path = self._cache_path + ".ts"
|
||||||
|
try:
|
||||||
|
if os.path.exists(ts_path):
|
||||||
|
with open(ts_path, "r", encoding="utf-8") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_lazy_ts(self) -> None:
|
||||||
|
"""Atomically persist per-card timestamps."""
|
||||||
|
ts_path = self._cache_path + ".ts"
|
||||||
|
tmp = ts_path + ".tmp"
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(self._lazy_ts, fh, separators=(",", ":"))
|
||||||
|
os.replace(tmp, ts_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to save lazy timestamps: %s", exc)
|
||||||
|
|
||||||
|
def get_stale_cards(self, threshold_hours: int = 24) -> set[str]:
|
||||||
|
"""Return the set of card names whose cached price is older than *threshold_hours*.
|
||||||
|
|
||||||
|
Uses the per-card timestamp sidecar (``prices_cache.json.ts``). If the
|
||||||
|
sidecar is absent, all priced cards are considered stale (safe default).
|
||||||
|
Returns an empty set when *threshold_hours* is 0 (warnings disabled).
|
||||||
|
Card names are returned in their original (display-name) casing as stored
|
||||||
|
in ``self._cache``.
|
||||||
|
"""
|
||||||
|
import time as _t
|
||||||
|
if threshold_hours <= 0:
|
||||||
|
return set()
|
||||||
|
cutoff = _t.time() - threshold_hours * 3600
|
||||||
|
with self._lock:
|
||||||
|
ts_map: dict[str, float] = dict(self._lazy_ts)
|
||||||
|
cached_keys: set[str] = set(self._cache.keys())
|
||||||
|
stale: set[str] = set()
|
||||||
|
for key in cached_keys:
|
||||||
|
ts = ts_map.get(key)
|
||||||
|
if ts is None or ts < cutoff:
|
||||||
|
stale.add(key)
|
||||||
|
return stale
|
||||||
|
|
||||||
|
def _persist_cache_snapshot(self) -> None:
|
||||||
|
"""Write the current in-memory cache to the JSON cache file (atomic)."""
|
||||||
|
import time as _t
|
||||||
|
with self._lock:
|
||||||
|
snapshot = dict(self._cache)
|
||||||
|
built = self._last_refresh or _t.time()
|
||||||
|
cache_data = {"prices": snapshot, "built_at": built}
|
||||||
|
tmp_path = self._cache_path + ".tmp"
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(cache_data, fh, separators=(",", ":"))
|
||||||
|
os.replace(tmp_path, self._cache_path)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ensure_loaded(self) -> None:
|
||||||
|
"""Lazy-load the price cache on first access (double-checked lock)."""
|
||||||
|
if self._loaded:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
if self._loaded:
|
||||||
|
return
|
||||||
|
self._load_or_rebuild()
|
||||||
|
self._loaded = True
|
||||||
|
|
||||||
|
def _load_or_rebuild(self) -> None:
|
||||||
|
"""Load from JSON cache if fresh; otherwise rebuild from bulk data."""
|
||||||
|
if os.path.exists(self._cache_path):
|
||||||
|
try:
|
||||||
|
age = time.time() - os.path.getmtime(self._cache_path)
|
||||||
|
if age < self._ttl:
|
||||||
|
self._load_from_cache_file()
|
||||||
|
logger.info(
|
||||||
|
"Loaded %d prices from cache (age %.1fh)",
|
||||||
|
len(self._cache),
|
||||||
|
age / 3600,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
logger.info("Price cache stale (%.1fh old), rebuilding", age / 3600)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Price cache unreadable, rebuilding: %s", exc)
|
||||||
|
self._rebuild_cache()
|
||||||
|
|
||||||
|
def _load_from_cache_file(self) -> None:
|
||||||
|
"""Deserialize the compact prices cache JSON into memory."""
|
||||||
|
with open(self._cache_path, "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
self._cache = data.get("prices", {})
|
||||||
|
self._last_refresh = data.get("built_at", 0.0)
|
||||||
|
|
||||||
|
def _rebuild_cache(self) -> None:
|
||||||
|
"""Stream the Scryfall bulk data file and extract prices.
|
||||||
|
|
||||||
|
Writes a compact cache JSON then swaps the in-memory dict.
|
||||||
|
Uses an atomic rename so concurrent readers see a complete file.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(self._bulk_path):
|
||||||
|
logger.warning("Scryfall bulk data not found at %s", self._bulk_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Building price cache from %s ...", self._bulk_path)
|
||||||
|
new_cache: Dict[str, Dict[str, float]] = {}
|
||||||
|
built_at = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self._bulk_path, "r", encoding="utf-8") as fh:
|
||||||
|
for raw_line in fh:
|
||||||
|
line = raw_line.strip().rstrip(",")
|
||||||
|
if not line or line in ("[", "]"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
card = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name: str = card.get("name", "")
|
||||||
|
prices: Dict[str, Any] = card.get("prices") or {}
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = self._extract_prices(prices)
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Index by both the combined name and each face name
|
||||||
|
names_to_index = [name]
|
||||||
|
if " // " in name:
|
||||||
|
names_to_index += [part.strip() for part in name.split(" // ")]
|
||||||
|
|
||||||
|
for idx_name in names_to_index:
|
||||||
|
key = idx_name.lower()
|
||||||
|
existing = new_cache.get(key)
|
||||||
|
# Prefer cheapest non-foil USD price across printings
|
||||||
|
new_usd = entry.get("usd", 9999.0)
|
||||||
|
if existing is None or new_usd < existing.get("usd", 9999.0):
|
||||||
|
new_cache[key] = entry
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to parse bulk data: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Write compact cache atomically
|
||||||
|
try:
|
||||||
|
cache_data = {"prices": new_cache, "built_at": built_at}
|
||||||
|
tmp_path = self._cache_path + ".tmp"
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(cache_data, fh, separators=(",", ":"))
|
||||||
|
os.replace(tmp_path, self._cache_path)
|
||||||
|
logger.info(
|
||||||
|
"Price cache written: %d cards → %s", len(new_cache), self._cache_path
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to write price cache: %s", exc)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._cache = new_cache
|
||||||
|
self._last_refresh = built_at
|
||||||
|
# Stamp all keys as fresh so get_stale_cards() reflects the rebuild
|
||||||
|
for key in new_cache:
|
||||||
|
self._lazy_ts[key] = built_at
|
||||||
|
self._save_lazy_ts()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_prices(prices: Dict[str, Any]) -> Dict[str, float]:
|
||||||
|
"""Convert raw Scryfall prices dict to {region_key: float} entries."""
|
||||||
|
result: Dict[str, float] = {}
|
||||||
|
for key in ("usd", "usd_foil", "eur", "eur_foil"):
|
||||||
|
raw = prices.get(key)
|
||||||
|
if raw is not None and raw != "":
|
||||||
|
try:
|
||||||
|
result[key] = float(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level singleton (lazy)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_INSTANCE: Optional[PriceService] = None
|
||||||
|
_INSTANCE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_price_service() -> PriceService:
|
||||||
|
"""Return the shared PriceService singleton, creating it on first call."""
|
||||||
|
global _INSTANCE
|
||||||
|
if _INSTANCE is None:
|
||||||
|
with _INSTANCE_LOCK:
|
||||||
|
if _INSTANCE is None:
|
||||||
|
_INSTANCE = PriceService()
|
||||||
|
return _INSTANCE
|
||||||
|
|
@ -5859,3 +5859,267 @@ footer.site-footer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Budget Mode — Badge, Tier Labels, Price Tooltip
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.budget-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .3rem .75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1.5px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-badge--under {
|
||||||
|
color: var(--ok, #16a34a);
|
||||||
|
background: color-mix(in srgb, var(--ok, #16a34a) 12%, var(--panel, #1a1b1e) 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-badge--soft_exceeded {
|
||||||
|
color: var(--warn, #f59e0b);
|
||||||
|
background: color-mix(in srgb, var(--warn, #f59e0b) 12%, var(--panel, #1a1b1e) 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-badge--hard_exceeded {
|
||||||
|
color: var(--err, #ef4444);
|
||||||
|
background: color-mix(in srgb, var(--err, #ef4444) 12%, var(--panel, #1a1b1e) 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tier badges on the pickups table */
|
||||||
|
.tier-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .1rem .5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
background: var(--panel, #1a1b1e);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-badge--s {
|
||||||
|
color: var(--ok, #16a34a);
|
||||||
|
border-color: var(--ok, #16a34a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-badge--m {
|
||||||
|
color: var(--warn, #f59e0b);
|
||||||
|
border-color: var(--warn, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-badge--l {
|
||||||
|
color: var(--muted, #b6b8bd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline price tooltip on card names */
|
||||||
|
.card-name-price-hover {
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-price-tip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface, #0f1115);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: .25rem .6rem;
|
||||||
|
font-size: .78rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 9000;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--text, #e5e7eb);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Price overlay on card thumbnails (step5 tiles + deck summary thumbs) */
|
||||||
|
.card-price-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, .72);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.card-price-overlay:empty { display: none; }
|
||||||
|
|
||||||
|
/* Inline price in deck summary list rows */
|
||||||
|
.card-price-inline {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted, #94a3b8);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.card-price-inline:empty { color: transparent; }
|
||||||
|
|
||||||
|
/* Over-budget highlight — gold/amber, matching the locked card style */
|
||||||
|
.card-tile.over-budget {
|
||||||
|
border-color: #f5c518 !important;
|
||||||
|
box-shadow: inset 0 0 8px rgba(245, 197, 24, .25), 0 0 5px #f5c518 !important;
|
||||||
|
}
|
||||||
|
.stack-card.over-budget {
|
||||||
|
border-color: #f5c518 !important;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.55), 0 0 7px #f5c518 !important;
|
||||||
|
}
|
||||||
|
.list-row.over-budget .name {
|
||||||
|
background: rgba(245, 197, 24, .12);
|
||||||
|
box-shadow: 0 0 0 1px #f5c518;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Budget price summary bar in deck summary */
|
||||||
|
.budget-price-bar {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: .3rem .5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: .4rem 0 .6rem 0;
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
}
|
||||||
|
.budget-price-bar.under { border-color: #34d399; color: #a7f3d0; }
|
||||||
|
.budget-price-bar.over { border-color: #f5c518; color: #fde68a; }
|
||||||
|
|
||||||
|
/* M5: Budget review panel */
|
||||||
|
.budget-review-panel {
|
||||||
|
border: 1px solid var(--border, #444);
|
||||||
|
border-left: 4px solid #f5c518;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
}
|
||||||
|
.budget-review-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
.budget-review-summary { flex: 1 1 auto; }
|
||||||
|
.budget-review-cards { display: flex; flex-direction: column; gap: .5rem; margin-top: .5rem; }
|
||||||
|
.budget-review-card-row {
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: .4rem .6rem;
|
||||||
|
background: var(--bg, #141824);
|
||||||
|
}
|
||||||
|
.budget-review-card-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
.budget-review-card-name { font-weight: 600; }
|
||||||
|
.budget-review-card-price { color: #f5c518; }
|
||||||
|
.budget-review-alts { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
||||||
|
.btn-alt-swap {
|
||||||
|
font-size: .8rem;
|
||||||
|
padding: .2rem .5rem;
|
||||||
|
border: 1px solid var(--border, #555);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .3rem;
|
||||||
|
}
|
||||||
|
.btn-alt-swap:hover { background: var(--hover, #252d3d); }
|
||||||
|
.alt-price { color: #34d399; font-size: .75rem; }
|
||||||
|
.budget-review-no-alts { font-size: .8rem; }
|
||||||
|
.budget-review-subtitle { font-size: .85rem; margin-bottom: .5rem; }
|
||||||
|
.budget-review-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||||
|
.chip-red { background: rgba(239,68,68,.15); color: #fca5a5; border-color: #ef4444; }
|
||||||
|
.chip-green { background: rgba(34,197,94,.15); color: #86efac; border-color: #22c55e; }
|
||||||
|
.chip-subtle { background: rgba(148,163,184,.08); color: var(--muted, #94a3b8); border-color: rgba(148,163,184,.2); font-size: .7rem; padding: 1px 6px; }
|
||||||
|
|
||||||
|
/* M8: Price category stacked bar */
|
||||||
|
.price-cat-section { margin: .6rem 0 .2rem 0; }
|
||||||
|
.price-cat-heading { font-size: 12px; color: var(--muted, #94a3b8); margin-bottom: .3rem; font-weight: 600; }
|
||||||
|
.price-cat-bar {
|
||||||
|
display: flex;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
}
|
||||||
|
.price-cat-seg {
|
||||||
|
height: 100%;
|
||||||
|
transition: opacity .15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.price-cat-seg:hover { opacity: .75; cursor: default; }
|
||||||
|
.price-cat-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .15rem .6rem;
|
||||||
|
margin-top: .3rem;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted, #94a3b8);
|
||||||
|
}
|
||||||
|
.price-cat-legend-item { display: flex; align-items: center; gap: .3rem; }
|
||||||
|
.price-cat-swatch { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* M8: Price histogram bars */
|
||||||
|
.price-hist-section { margin: .75rem 0 .2rem 0; }
|
||||||
|
.price-hist-heading { font-size: 12px; color: var(--muted, #94a3b8); margin-bottom: .3rem; font-weight: 600; }
|
||||||
|
.price-hist-bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.price-hist-column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.price-hist-column:hover { opacity: .8; }
|
||||||
|
.price-hist-bar {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
}
|
||||||
|
.price-hist-xlabels {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
.price-hist-xlabel {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted, #94a3b8);
|
||||||
|
text-align: center;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.price-hist-count { font-size: 11px; color: var(--muted, #94a3b8); margin-top: .1rem; }
|
||||||
|
|
||||||
|
/* M9: Stale price indicators */
|
||||||
|
.stale-price-indicator { position: absolute; top: 4px; right: 4px; font-size: 10px; color: #f59e0b; cursor: default; pointer-events: auto; z-index: 2; }
|
||||||
|
.stale-price-badge { font-size: 10px; color: #f59e0b; margin-left: 2px; vertical-align: middle; cursor: default; }
|
||||||
|
.stale-banner { background: rgba(245,158,11,.08); border: 1px solid rgba(245,158,11,.35); border-radius: 6px; padding: .4rem .75rem; font-size: 12px; color: #f59e0b; margin-bottom: .6rem; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ interface PointerEventLike {
|
||||||
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">' +
|
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">' +
|
||||||
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> </div>' +
|
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> </div>' +
|
||||||
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>' +
|
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>' +
|
||||||
|
'<div class="hcp-price" style="font-size:12px;font-weight:500;white-space:nowrap;"></div>' +
|
||||||
'<button type="button" class="hcp-close" aria-label="Close card details"><span aria-hidden="true">✕</span></button>' +
|
'<button type="button" class="hcp-close" aria-label="Close card details"><span aria-hidden="true">✕</span></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="hcp-body">' +
|
'<div class="hcp-body">' +
|
||||||
|
|
@ -158,6 +159,7 @@ interface PointerEventLike {
|
||||||
const imgEl = panel.querySelector('.hcp-img') as HTMLImageElement;
|
const imgEl = panel.querySelector('.hcp-img') as HTMLImageElement;
|
||||||
const nameEl = panel.querySelector('.hcp-name') as HTMLElement;
|
const nameEl = panel.querySelector('.hcp-name') as HTMLElement;
|
||||||
const rarityEl = panel.querySelector('.hcp-rarity') as HTMLElement;
|
const rarityEl = panel.querySelector('.hcp-rarity') as HTMLElement;
|
||||||
|
const priceEl = panel.querySelector('.hcp-price') as HTMLElement;
|
||||||
const metaEl = panel.querySelector('.hcp-meta') as HTMLElement;
|
const metaEl = panel.querySelector('.hcp-meta') as HTMLElement;
|
||||||
const reasonsList = panel.querySelector('.hcp-reasons') as HTMLElement;
|
const reasonsList = panel.querySelector('.hcp-reasons') as HTMLElement;
|
||||||
const tagsEl = panel.querySelector('.hcp-tags') as HTMLElement;
|
const tagsEl = panel.querySelector('.hcp-tags') as HTMLElement;
|
||||||
|
|
@ -393,6 +395,14 @@ interface PointerEventLike {
|
||||||
|
|
||||||
nameEl.textContent = nm;
|
nameEl.textContent = nm;
|
||||||
rarityEl.textContent = rarity;
|
rarityEl.textContent = rarity;
|
||||||
|
if (priceEl) {
|
||||||
|
const priceRaw = (attr('data-price') || '').trim();
|
||||||
|
const priceNum = priceRaw ? parseFloat(priceRaw) : NaN;
|
||||||
|
const isStale = attr('data-stale') === '1';
|
||||||
|
priceEl.innerHTML = !isNaN(priceNum)
|
||||||
|
? '$' + priceNum.toFixed(2) + (isStale ? ' <span style="color:#f59e0b;font-size:10px;" title="Price may be outdated (>24h)">\u23F1</span>' : '')
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
const roleLabel = displayLabel(role);
|
const roleLabel = displayLabel(role);
|
||||||
const roleKey = (roleLabel || role || '').toLowerCase();
|
const roleKey = (roleLabel || role || '').toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@
|
||||||
Card images and data provided by
|
Card images and data provided by
|
||||||
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
|
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
|
||||||
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
|
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
|
||||||
|
{% set _pba = _price_cache_ts() %}{% if _pba %}<br><span class="muted" style="font-size:.8em;">Prices as of {{ _pba }} — for live pricing visit <a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.</span>{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
<!-- Card hover, theme badges, and DFC toggle styles moved to tailwind.css 2025-10-21 -->
|
<!-- Card hover, theme badges, and DFC toggle styles moved to tailwind.css 2025-10-21 -->
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -388,5 +389,164 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Toast after reload, setup poller, nav highlighter moved to app.ts -->
|
<!-- Toast after reload, setup poller, nav highlighter moved to app.ts -->
|
||||||
<!-- Hover card panel system moved to cardHover.ts -->
|
<!-- Hover card panel system moved to cardHover.ts -->
|
||||||
|
<!-- Price tooltip: lightweight fetch on mouseenter for .card-name-price-hover -->
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var _priceCache = {};
|
||||||
|
var _tip = null;
|
||||||
|
function _showTip(el, text) {
|
||||||
|
if (!_tip) {
|
||||||
|
_tip = document.createElement('div');
|
||||||
|
_tip.className = 'card-price-tip';
|
||||||
|
document.body.appendChild(_tip);
|
||||||
|
}
|
||||||
|
_tip.textContent = text;
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
_tip.style.left = (r.left + r.width/2 + window.scrollX) + 'px';
|
||||||
|
_tip.style.top = (r.top + window.scrollY - 4) + 'px';
|
||||||
|
_tip.style.transform = 'translate(-50%, -100%)';
|
||||||
|
_tip.style.display = 'block';
|
||||||
|
}
|
||||||
|
function _hideTip() { if (_tip) _tip.style.display = 'none'; }
|
||||||
|
document.addEventListener('mouseover', function(e) {
|
||||||
|
var el = e.target && e.target.closest && e.target.closest('.card-name-price-hover');
|
||||||
|
if (!el) return;
|
||||||
|
var name = el.dataset.cardName || el.textContent.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (_priceCache[name] !== undefined) {
|
||||||
|
_showTip(el, _priceCache[name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showTip(el, 'Loading price...');
|
||||||
|
fetch('/api/price/' + encodeURIComponent(name))
|
||||||
|
.then(function(r){ return r.ok ? r.json() : null; })
|
||||||
|
.then(function(d){
|
||||||
|
var label = (d && d.found && d.price != null) ? ('$' + parseFloat(d.price).toFixed(2)) : 'Price unavailable';
|
||||||
|
_priceCache[name] = label;
|
||||||
|
_showTip(el, label);
|
||||||
|
})
|
||||||
|
.catch(function(){ _priceCache[name] = 'Price unavailable'; });
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseout', function(e) {
|
||||||
|
var el = e.target && e.target.closest && e.target.closest('.card-name-price-hover');
|
||||||
|
if (el) _hideTip();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- Budget price display: injects prices on card tiles and list rows, tracks running total -->
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var BASIC_LANDS = new Set([
|
||||||
|
'Plains','Island','Swamp','Mountain','Forest','Wastes',
|
||||||
|
'Snow-Covered Plains','Snow-Covered Island','Snow-Covered Swamp',
|
||||||
|
'Snow-Covered Mountain','Snow-Covered Forest'
|
||||||
|
]);
|
||||||
|
var _priceNum = {}; // card name -> float|null
|
||||||
|
var _deckPrices = {}; // accumulated across build stages: card name -> float
|
||||||
|
var _buildToken = null;
|
||||||
|
function _fetchNum(name) {
|
||||||
|
if (_priceNum.hasOwnProperty(name)) return Promise.resolve(_priceNum[name]);
|
||||||
|
return fetch('/api/price/' + encodeURIComponent(name))
|
||||||
|
.then(function(r){ return r.ok ? r.json() : null; })
|
||||||
|
.then(function(d){
|
||||||
|
var p = (d && d.found && d.price != null) ? parseFloat(d.price) : null;
|
||||||
|
_priceNum[name] = p; return p;
|
||||||
|
}).catch(function(){ _priceNum[name] = null; return null; });
|
||||||
|
}
|
||||||
|
function _getBuildToken() {
|
||||||
|
var el = document.querySelector('[data-build-id]');
|
||||||
|
return el ? el.getAttribute('data-build-id') : null;
|
||||||
|
}
|
||||||
|
function _cfg() { return window._budgetCfg || null; }
|
||||||
|
function initPriceDisplay() {
|
||||||
|
var tok = _getBuildToken();
|
||||||
|
if (tok !== null && tok !== _buildToken) { _buildToken = tok; _deckPrices = {}; }
|
||||||
|
var cfg = _cfg();
|
||||||
|
var ceiling = cfg && cfg.card_ceiling ? parseFloat(cfg.card_ceiling) : null;
|
||||||
|
var totalCap = cfg && cfg.total ? parseFloat(cfg.total) : null;
|
||||||
|
|
||||||
|
function updateRunningTotal(prevTotal) {
|
||||||
|
var chip = document.getElementById('budget-running');
|
||||||
|
if (!chip) return;
|
||||||
|
var total = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
|
||||||
|
chip.textContent = total.toFixed(2);
|
||||||
|
var chipWrap = chip.closest('.chip');
|
||||||
|
if (chipWrap && totalCap !== null) chipWrap.classList.toggle('chip-warn', total > totalCap);
|
||||||
|
if (prevTotal !== undefined) {
|
||||||
|
var stepAdded = total - prevTotal;
|
||||||
|
var stepEl = document.getElementById('budget-step');
|
||||||
|
if (stepEl && stepAdded > 0.005) {
|
||||||
|
stepEl.textContent = '+$' + stepAdded.toFixed(2) + ' this step';
|
||||||
|
stepEl.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var overlays = document.querySelectorAll('.card-price-overlay[data-price-for]');
|
||||||
|
var inlines = document.querySelectorAll('.card-price-inline[data-price-for]');
|
||||||
|
var toFetch = new Set();
|
||||||
|
overlays.forEach(function(el){ var n = el.dataset.priceFor; if (n && !BASIC_LANDS.has(n)) toFetch.add(n); });
|
||||||
|
inlines.forEach(function(el){ var n = el.dataset.priceFor; if (n && !BASIC_LANDS.has(n)) toFetch.add(n); });
|
||||||
|
|
||||||
|
// Always refresh the running total chip even when there's nothing new to fetch
|
||||||
|
updateRunningTotal();
|
||||||
|
if (!toFetch.size) return;
|
||||||
|
|
||||||
|
var prevTotal = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
|
||||||
|
var promises = [];
|
||||||
|
toFetch.forEach(function(name){ promises.push(_fetchNum(name).then(function(p){ return {name:name,price:p}; })); });
|
||||||
|
Promise.all(promises).then(function(results){
|
||||||
|
var map = {};
|
||||||
|
var prevTotal2 = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
|
||||||
|
results.forEach(function(r){ map[r.name] = r.price; if (r.price !== null) _deckPrices[r.name] = r.price; });
|
||||||
|
overlays.forEach(function(el){
|
||||||
|
var name = el.dataset.priceFor;
|
||||||
|
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
|
||||||
|
var p = map[name];
|
||||||
|
el.textContent = p !== null ? ('$' + p.toFixed(2)) : '';
|
||||||
|
if (ceiling !== null && p !== null && p > ceiling) {
|
||||||
|
var tile = el.closest('.card-tile,.stack-card');
|
||||||
|
if (tile) tile.classList.add('over-budget');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
inlines.forEach(function(el){
|
||||||
|
var name = el.dataset.priceFor;
|
||||||
|
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
|
||||||
|
var p = map[name];
|
||||||
|
el.textContent = p !== null ? ('$' + p.toFixed(2)) : '';
|
||||||
|
if (ceiling !== null && p !== null && p > ceiling) {
|
||||||
|
var row = el.closest('.list-row');
|
||||||
|
if (row) row.classList.add('over-budget');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Update running total chip with per-step delta
|
||||||
|
updateRunningTotal(prevTotal2);
|
||||||
|
// Update summary budget bar
|
||||||
|
var bar = document.getElementById('budget-summary-bar');
|
||||||
|
if (bar) {
|
||||||
|
var allNames = new Set();
|
||||||
|
var sumTotal = 0;
|
||||||
|
document.querySelectorAll('.card-price-overlay[data-price-for],.card-price-inline[data-price-for]').forEach(function(el){
|
||||||
|
var n = el.dataset.priceFor;
|
||||||
|
if (n && !BASIC_LANDS.has(n) && !allNames.has(n)) {
|
||||||
|
allNames.add(n);
|
||||||
|
sumTotal += (map[n] || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (totalCap !== null) {
|
||||||
|
var over = sumTotal > totalCap;
|
||||||
|
bar.textContent = 'Estimated deck cost: $' + sumTotal.toFixed(2) + ' / $' + totalCap.toFixed(2) + (over ? ' — over budget' : ' — under budget');
|
||||||
|
bar.className = over ? 'budget-price-bar over' : 'budget-price-bar under';
|
||||||
|
} else {
|
||||||
|
bar.textContent = 'Estimated deck cost: $' + sumTotal.toFixed(2) + ' (basic lands excluded)';
|
||||||
|
bar.className = 'budget-price-bar';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', function(){ initPriceDisplay(); });
|
||||||
|
document.addEventListener('htmx:afterSwap', function(){ setTimeout(initPriceDisplay, 80); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,14 @@
|
||||||
{% if it.rarity %}data-rarity="{{ it.rarity }}"{% endif %}
|
{% if it.rarity %}data-rarity="{{ it.rarity }}"{% endif %}
|
||||||
{% if it.hover_simple %}data-hover-simple="1"{% endif %}
|
{% if it.hover_simple %}data-hover-simple="1"{% endif %}
|
||||||
{% if it.owned %}data-owned="1"{% endif %}
|
{% if it.owned %}data-owned="1"{% endif %}
|
||||||
|
{% if it.price %}data-price="{{ it.price }}"{% endif %}
|
||||||
data-tags="{{ tags|join(', ') }}"
|
data-tags="{{ tags|join(', ') }}"
|
||||||
hx-post="/build/replace"
|
hx-post="/build/replace"
|
||||||
hx-vals='{"old":"{{ name }}", "new":"{{ it.name }}", "owned_only":"{{ 1 if require_owned else 0 }}"}'
|
hx-vals='{"old":"{{ name }}", "new":"{{ it.name }}", "owned_only":"{{ 1 if require_owned else 0 }}"}'
|
||||||
hx-target="closest .alts"
|
hx-target="closest .alts"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
title="Lock this alternative and unlock the current pick">
|
title="Lock this alternative and unlock the current pick">
|
||||||
{{ it.name }}
|
{{ it.name }}{% if it.price %} <span style="font-size:11px;opacity:.7;font-weight:normal;">${{ "%.2f"|format(it.price|float) }}</span>{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
79
code/web/templates/build/_budget_review.html
Normal file
79
code/web/templates/build/_budget_review.html
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
{% if budget_review_visible %}
|
||||||
|
<div id="budget-review-panel" class="budget-review-panel mt-4">
|
||||||
|
<div class="budget-review-header">
|
||||||
|
<strong>Budget Review</strong>
|
||||||
|
<span class="budget-review-summary">
|
||||||
|
Your deck costs <strong>${{ '%.2f'|format(budget_review_total|float) }}</strong>
|
||||||
|
{% if over_budget_review %}
|
||||||
|
— {{ budget_overage_pct }}% over your ${{ '%.2f'|format(budget_review_cap|float) }} cap.
|
||||||
|
{% else %}
|
||||||
|
— within your ${{ '%.2f'|format(budget_review_cap|float) }} cap.
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if over_budget_review %}
|
||||||
|
<span class="chip chip-yellow">Advisory: deck is over budget</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="chip chip-green">Within budget</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if over_budget_cards %}
|
||||||
|
<div class="budget-review-cards">
|
||||||
|
<p class="muted budget-review-subtitle">Most expensive cards — swapping saves the most on total cost:</p>
|
||||||
|
{% for card in over_budget_cards %}
|
||||||
|
<div class="budget-review-card-row">
|
||||||
|
<div class="budget-review-card-info">
|
||||||
|
<span class="budget-review-card-name"
|
||||||
|
data-card-name="{{ card.name }}"
|
||||||
|
data-role="{{ card.card_role }}"
|
||||||
|
data-tags="{{ card.card_tags|join(', ') if card.card_tags else '' }}"
|
||||||
|
style="cursor:default;">{{ card.name }}</span>
|
||||||
|
<span class="budget-review-card-price">${{ '%.2f'|format(card.price|float) }}</span>{% if stale_prices is defined and card.name|lower in stale_prices %}<span class="stale-price-badge" title="Price may be outdated (>24h)">⏱</span>{% endif %}
|
||||||
|
{% if card.card_type %}
|
||||||
|
<span class="chip chip-subtle text-xs" title="Card type">{{ card.card_type }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if card.card_role %}
|
||||||
|
<span class="chip chip-subtle text-xs" title="Role in deck">{{ card.card_role }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if card.swap_disabled %}
|
||||||
|
<span class="chip" title="This card is in your include list">Required</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if not card.swap_disabled and card.alternatives %}
|
||||||
|
<div class="budget-review-alts">
|
||||||
|
<span class="muted">Swap for:</span>
|
||||||
|
{% for alt in card.alternatives %}
|
||||||
|
<form hx-post="/build/budget-swap"
|
||||||
|
hx-target="#budget-review-panel"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="inline-form">
|
||||||
|
<input type="hidden" name="old_card" value="{{ card.name }}" />
|
||||||
|
<input type="hidden" name="new_card" value="{{ alt.name }}" />
|
||||||
|
<button type="submit" class="btn-alt-swap"
|
||||||
|
data-card-name="{{ alt.name }}"
|
||||||
|
data-hover-simple="1"
|
||||||
|
{% if alt.price %}data-price="{{ alt.price }}"{% endif %}
|
||||||
|
title="{{ alt.shared_tags|join(', ') if alt.shared_tags else '' }}">
|
||||||
|
{{ alt.name }}
|
||||||
|
{% if alt.price %}<span class="alt-price">${{ '%.2f'|format(alt.price|float) }}</span>{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% elif not card.swap_disabled %}
|
||||||
|
<div class="muted budget-review-no-alts">No affordable alternatives found</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Price data unavailable for over-budget cards.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="budget-review-actions mt-2">
|
||||||
|
<button type="button"
|
||||||
|
onclick="document.getElementById('budget-review-panel').remove()"
|
||||||
|
class="btn">Accept deck as-is</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
@ -208,6 +208,34 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "build/_new_deck_skip_controls.html" %}
|
{% include "build/_new_deck_skip_controls.html" %}
|
||||||
|
{% if enable_budget_mode %}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Budget</legend>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<label class="block">
|
||||||
|
<span>Total budget ($)</span>
|
||||||
|
<small class="muted block text-xs mt-1">Set a deck cost ceiling — cards over budget will be flagged</small>
|
||||||
|
<input type="number" name="budget_total" id="budget_total" min="0" step="0.01"
|
||||||
|
placeholder="e.g. 150.00"
|
||||||
|
value="{{ form.budget_total if form and form.budget_total else '' }}" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span>Per-card ceiling ($) <small class="muted">(optional)</small></span>
|
||||||
|
<small class="muted block text-xs mt-1">Flag individual cards above this price</small>
|
||||||
|
<input type="number" name="card_ceiling" id="card_ceiling" min="0" step="0.01"
|
||||||
|
placeholder="e.g. 10.00"
|
||||||
|
value="{{ form.card_ceiling if form and form.card_ceiling else '' }}" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span>Pool filter tolerance (%)</span>
|
||||||
|
<small class="muted block text-xs mt-1">Cards exceeding the per-card ceiling by more than this % are excluded from the card pool. Set to 0 to hard-cap at the ceiling exactly.</small>
|
||||||
|
<input type="number" name="pool_tolerance" id="pool_tolerance" min="0" max="100" step="1"
|
||||||
|
value="{{ form.pool_tolerance if form is not none else '15' }}" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
{% if enable_batch_build %}
|
{% if enable_batch_build %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Build Options</legend>
|
<legend>Build Options</legend>
|
||||||
|
|
@ -238,7 +266,7 @@
|
||||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||||
<div class="modal-footer-left">
|
<div class="modal-footer-left">
|
||||||
<button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button>
|
<button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button>
|
||||||
<button type="submit" class="btn-continue" id="create-btn">Create</button>
|
<button type="submit" class="btn-continue" id="create-btn">Build Deck</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if allow_must_haves and multi_copy_archetypes_js %}
|
{% if allow_must_haves and multi_copy_archetypes_js %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{# Quick Build Progress Indicator - Current Stage + Completed List #}
|
{# Quick Build Progress Indicator - Current Stage + Completed List #}
|
||||||
|
|
||||||
<div id="wizard" class="wizard-container" style="max-width:1200px; margin:2rem auto; padding:2rem;">
|
<div class="wizard-container" style="max-width:1200px; margin:2rem auto; padding:2rem;">
|
||||||
<div id="wizard-content">
|
<div id="wizard-content">
|
||||||
{% include "build/_quick_build_progress_content.html" %}
|
{% include "build/_quick_build_progress_content.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
{% set hover_tags_joined = hover_tags_source|join(', ') %}
|
{% set hover_tags_joined = hover_tags_source|join(', ') %}
|
||||||
{% set display_tags = display_tags_source if display_tags_source else [] %}
|
{% set display_tags = display_tags_source if display_tags_source else [] %}
|
||||||
{% set show_color_identity = color_label or (color_identity_list|length > 0) %}
|
{% set show_color_identity = color_label or (color_identity_list|length > 0) %}
|
||||||
<section>
|
<section data-build-id="{{ build_id }}">
|
||||||
{# Step phases removed #}
|
{# Step phases removed #}
|
||||||
<div class="two-col two-col-left-rail">
|
<div class="two-col two-col-left-rail">
|
||||||
<aside class="card-preview">
|
<aside class="card-preview">
|
||||||
|
|
@ -186,6 +186,10 @@
|
||||||
<span class="chip" title="Multi-Copy package summary"><span class="dot dot-purple"></span> {{ mc_summary }}</span>
|
<span class="chip" title="Multi-Copy package summary"><span class="dot dot-purple"></span> {{ mc_summary }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
||||||
|
{% if budget_config and budget_config.total %}
|
||||||
|
<span class="chip" id="budget-chip"><span class="dot dot-yellow"></span> $<span id="budget-running">...</span> / ${{ '%.2f'|format(budget_config.total|float) }} cap</span>
|
||||||
|
<span id="budget-step" class="muted text-xs" style="display:none"></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
||||||
|
|
@ -214,6 +218,67 @@
|
||||||
<div hx-get="/build/compliance" hx-trigger="load" hx-swap="afterend"></div>
|
<div hx-get="/build/compliance" hx-trigger="load" hx-swap="afterend"></div>
|
||||||
{% if status and status.startswith('Build complete') %}
|
{% if status and status.startswith('Build complete') %}
|
||||||
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
|
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
|
||||||
|
{# M5: Budget review panel — shown in main content when deck total exceeds cap #}
|
||||||
|
{% include 'build/_budget_review.html' %}
|
||||||
|
{# M8: Price charts accordion — shown when budget mode was enabled and prices loaded #}
|
||||||
|
{% if (price_category_chart and price_category_chart.total > 0) or price_histogram_chart %}
|
||||||
|
<details class="analytics-accordion mt-4" id="price-charts-accordion">
|
||||||
|
<summary class="combo-summary">
|
||||||
|
<span>Price Breakdown</span>
|
||||||
|
<span class="muted text-xs font-normal ml-2">spend by category & distribution</span>
|
||||||
|
</summary>
|
||||||
|
<div class="analytics-content mt-3">
|
||||||
|
{% if price_category_chart and price_category_chart.total > 0 %}
|
||||||
|
<div class="price-cat-section">
|
||||||
|
<div class="price-cat-heading">Spend by Category — ${{ '%.2f'|format(price_category_chart.total) }} total</div>
|
||||||
|
<div class="price-cat-bar" title="Total: ${{ '%.2f'|format(price_category_chart.total) }}">
|
||||||
|
{% for cat in price_category_chart.order %}
|
||||||
|
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
|
||||||
|
{% if cat_total > 0 %}
|
||||||
|
{% set pct = (cat_total * 100 / price_category_chart.total) | round(1) %}
|
||||||
|
<div class="price-cat-seg"
|
||||||
|
style="width:{{ pct }}%; background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"
|
||||||
|
title="{{ cat }}: ${{ '%.2f'|format(cat_total) }} ({{ pct }}%)"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="price-cat-legend">
|
||||||
|
{% for cat in price_category_chart.order %}
|
||||||
|
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
|
||||||
|
{% if cat_total > 0 %}
|
||||||
|
<span class="price-cat-legend-item">
|
||||||
|
<span class="price-cat-swatch" style="background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"></span>
|
||||||
|
{{ cat }} ${{ '%.2f'|format(cat_total) }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if price_histogram_chart %}
|
||||||
|
<div class="price-hist-section">
|
||||||
|
<div class="price-hist-heading">Price Distribution</div>
|
||||||
|
<div class="price-hist-bars">
|
||||||
|
{% for bin in price_histogram_chart %}
|
||||||
|
<div class="price-hist-column"
|
||||||
|
data-type="hist"
|
||||||
|
data-range="${{ '%.2f'|format(bin.range_min) }}–${{ '%.2f'|format(bin.range_max) }}"
|
||||||
|
data-val="{{ bin.count }}"
|
||||||
|
data-cards="{% for c in bin.cards %}{{ c.name }}|{{ '%.2f'|format(c.price) }}{% if not loop.last %} • {% endif %}{% endfor %}">
|
||||||
|
<div class="price-hist-bar" style="height:{{ bin.pct | default(0) }}%; background:{{ bin.color }};"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="price-hist-xlabels">
|
||||||
|
{% for bin in price_histogram_chart %}
|
||||||
|
<div class="price-hist-xlabel">{{ bin.x_label }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if locked_cards is defined and locked_cards %}
|
{% if locked_cards is defined and locked_cards %}
|
||||||
|
|
@ -238,7 +303,8 @@
|
||||||
<!-- Last action chip (oob-updated) -->
|
<!-- Last action chip (oob-updated) -->
|
||||||
<div id="last-action" aria-live="polite" class="my-1 last-action"></div>
|
<div id="last-action" aria-live="polite" class="my-1 last-action"></div>
|
||||||
|
|
||||||
<!-- Filters toolbar -->
|
{% if not (status and status.startswith('Build complete')) %}
|
||||||
|
<!-- Filters toolbar (only during active build stages) -->
|
||||||
<div class="cards-toolbar">
|
<div class="cards-toolbar">
|
||||||
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
|
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
|
||||||
<select name="filter_owned" data-pref="cards:owned">
|
<select name="filter_owned" data-pref="cards:owned">
|
||||||
|
|
@ -267,21 +333,37 @@
|
||||||
<span class="chip" data-chip-clear>Clear</span>
|
<span class="chip" data-chip-clear>Clear</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status and status.startswith('Build complete') %}
|
||||||
|
<!-- Minimal controls at build complete -->
|
||||||
|
<div class="build-controls">
|
||||||
|
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||||||
|
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||||
|
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||||||
|
</form>
|
||||||
|
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" class="inline-form">
|
||||||
|
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
<!-- Sticky build controls on mobile -->
|
<!-- Sticky build controls on mobile -->
|
||||||
<div class="build-controls">
|
<div class="build-controls">
|
||||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||||
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if not (status and status.startswith('Build complete')) %}
|
||||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Continuing…'); }catch(_){}">
|
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Continuing…'); }catch(_){}">
|
||||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||||
<button type="submit" class="btn-continue" data-action="continue" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Continue</button>
|
<button type="submit" class="btn-continue" data-action="continue" {% if gated %}disabled{% endif %}>Continue</button>
|
||||||
</form>
|
</form>
|
||||||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
|
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
|
||||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||||
<button type="submit" class="btn-rerun" data-action="rerun" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Rerun Stage</button>
|
<button type="submit" class="btn-rerun" data-action="rerun" {% if gated %}disabled{% endif %}>Rerun Stage</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<div class="replace-toggle" role="group" aria-label="Replace toggle">
|
<div class="replace-toggle" role="group" aria-label="Replace toggle">
|
||||||
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" class="inline-form">
|
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" class="inline-form">
|
||||||
|
|
@ -306,6 +388,7 @@
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if added_cards is not none %}
|
{% if added_cards is not none %}
|
||||||
{% if history is defined and history %}
|
{% if history is defined and history %}
|
||||||
|
|
@ -333,7 +416,9 @@
|
||||||
<span><span class="ownership-badge">✔</span> Owned</span>
|
<span><span class="ownership-badge">✔</span> Owned</span>
|
||||||
<span><span class="ownership-badge">✖</span> Not owned</span>
|
<span><span class="ownership-badge">✖</span> Not owned</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if stale_prices_global is defined and stale_prices_global %}
|
||||||
|
<div class="stale-banner">⏱ Prices shown may be more than 24 hours old. Refresh price data on the Setup page if you need current values.</div>
|
||||||
|
{% endif %}
|
||||||
{% if stage_label and stage_label.startswith('Creatures') %}
|
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||||
{% set groups = added_cards|groupby('sub_role') %}
|
{% set groups = added_cards|groupby('sub_role') %}
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
|
|
@ -356,13 +441,17 @@
|
||||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
|
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
|
||||||
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}"
|
||||||
|
data-price="{{ (price_cache or {}).get(c.name.lower(), {}).get('usd') or '' }}"
|
||||||
|
data-stale="{% if stale_prices is defined and c.name|lower in stale_prices %}1{% else %}0{% endif %}">
|
||||||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||||
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||||||
sizes="160px" />
|
sizes="160px" />
|
||||||
</div>
|
</div>
|
||||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||||
|
<div class="card-price-overlay" data-price-for="{{ c.name }}" aria-hidden="true"></div>
|
||||||
|
{% if stale_prices is defined and c.name|lower in stale_prices %}<div class="stale-price-indicator" title="Price may be outdated (>24h)">⏱</div>{% endif %}
|
||||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
||||||
{% from 'partials/_macros.html' import lock_button %}
|
{% from 'partials/_macros.html' import lock_button %}
|
||||||
|
|
@ -402,13 +491,17 @@
|
||||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
|
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
|
||||||
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}"
|
||||||
|
data-price="{{ (price_cache or {}).get(c.name.lower(), {}).get('usd') or '' }}"
|
||||||
|
data-stale="{% if stale_prices is defined and c.name|lower in stale_prices %}1{% else %}0{% endif %}">
|
||||||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||||
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||||||
sizes="160px" />
|
sizes="160px" />
|
||||||
</div>
|
</div>
|
||||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||||
|
<div class="card-price-overlay" data-price-for="{{ c.name }}" aria-hidden="true"></div>
|
||||||
|
{% if stale_prices is defined and c.name|lower in stale_prices %}<div class="stale-price-indicator" title="Price may be outdated (>24h)">⏱</div>{% endif %}
|
||||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||||
<div class="lock-box" id="lock-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
<div class="lock-box" id="lock-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
||||||
{% from 'partials/_macros.html' import lock_button %}
|
{% from 'partials/_macros.html' import lock_button %}
|
||||||
|
|
@ -463,6 +556,9 @@
|
||||||
{% set oob = False %}
|
{% set oob = False %}
|
||||||
{% include "partials/include_exclude_summary.html" %}
|
{% include "partials/include_exclude_summary.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if budget_config and budget_config.total %}
|
||||||
|
<script>window._budgetCfg={"total":{{ budget_config.total|float }},"card_ceiling":{{ budget_config.card_ceiling|float if budget_config.card_ceiling else 'null' }}};</script>
|
||||||
|
{% endif %}
|
||||||
<div id="deck-summary" data-summary
|
<div id="deck-summary" data-summary
|
||||||
hx-get="/build/step5/summary?token={{ summary_token }}"
|
hx-get="/build/step5/summary?token={{ summary_token }}"
|
||||||
hx-trigger="load once, step5:refresh from:body"
|
hx-trigger="load once, step5:refresh from:body"
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,8 @@
|
||||||
<input type="checkbox" class="deck-select" aria-label="Select deck {{ it.name }} for comparison" />
|
<input type="checkbox" class="deck-select" aria-label="Select deck {{ it.name }} for comparison" />
|
||||||
<span class="muted" style="font-size:12px;">Select</span>
|
<span class="muted" style="font-size:12px;">Select</span>
|
||||||
</label>
|
</label>
|
||||||
<form action="/files" method="get" style="display:inline; margin:0;">
|
<form action="/decks/download-csv" method="get" style="display:inline; margin:0;">
|
||||||
<input type="hidden" name="path" value="{{ it.path }}" />
|
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||||
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
|
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
|
||||||
</form>
|
</form>
|
||||||
{% if it.txt_path %}
|
{% if it.txt_path %}
|
||||||
|
|
|
||||||
104
code/web/templates/decks/pickups.html
Normal file
104
code/web/templates/decks/pickups.html
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block banner_subtitle %}Budget Pickups{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Pickups List</h2>
|
||||||
|
{% if commander %}
|
||||||
|
<div class="muted" style="margin-bottom:.5rem;">Deck: <strong>{{ commander }}</strong>{% if name %} — <span class="muted text-xs">{{ name }}</span>{% endif %}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="panel panel-info-warning">{{ error }}</div>
|
||||||
|
{% elif not budget_report %}
|
||||||
|
<div class="panel">No budget report available for this deck.</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% set bstatus = budget_report.budget_status %}
|
||||||
|
<div class="budget-badge budget-badge--{{ bstatus }}" style="margin-bottom:.75rem;">
|
||||||
|
{% if bstatus == 'under' %}
|
||||||
|
Under Budget: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||||
|
{% elif bstatus == 'soft_exceeded' %}
|
||||||
|
Over Budget (soft): ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||||
|
(+${{ "%.2f"|format(budget_report.overage) }})
|
||||||
|
{% else %}
|
||||||
|
Hard Cap Exceeded: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||||
|
(+${{ "%.2f"|format(budget_report.overage) }})
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if stale_prices_global is defined and stale_prices_global %}
|
||||||
|
<div class="stale-banner">⏱ Prices shown may be more than 24 hours old. Refresh price data on the Setup page if you need current values.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if budget_report.pickups_list %}
|
||||||
|
<p class="muted text-sm" style="margin-bottom:.5rem;">
|
||||||
|
Cards you don't own yet that fit the deck's themes and budget. Sorted by theme match priority.
|
||||||
|
</p>
|
||||||
|
<table class="pickups-table" style="width:100%; border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Card</th>
|
||||||
|
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Price</th>
|
||||||
|
<th style="text-align:center; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Tier</th>
|
||||||
|
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Priority</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for card in budget_report.pickups_list %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
||||||
|
<span class="card-name-price-hover" data-card-name="{{ card.card|e }}">{{ card.card }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
||||||
|
{% if card.price is not none %}
|
||||||
|
${{ "%.2f"|format(card.price) }}{% if stale_prices is defined and card.card|lower in stale_prices %}<span class="stale-price-badge" title="Price may be outdated (>24h)">⏱</span>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">–</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
||||||
|
<span class="tier-badge tier-badge--{{ card.tier|lower }}">{{ card.tier }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);" class="muted">
|
||||||
|
{{ card.priority }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="panel muted">No pickups suggestions available — your deck may already fit the budget well.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if budget_report.over_budget_cards %}
|
||||||
|
<h3 style="margin-top:1.25rem;">Over-Budget Cards</h3>
|
||||||
|
<p class="muted text-sm" style="margin-bottom:.5rem;">
|
||||||
|
Cards in your current deck that exceed the budget or per-card ceiling.
|
||||||
|
</p>
|
||||||
|
<table style="width:100%; border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Card</th>
|
||||||
|
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Price</th>
|
||||||
|
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in budget_report.over_budget_cards %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">{{ c.card }}</td>
|
||||||
|
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">${{ "%.2f"|format(c.price) }}{% if stale_prices is defined and c.card|lower in stale_prices %}<span class="stale-price-badge" title="Price may be outdated (>24h)">⏱</span>{% endif %}</td>
|
||||||
|
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);" class="muted">
|
||||||
|
{% if c.ceiling_exceeded %}Above ${{ "%.2f"|format(budget_config.card_ceiling) }} ceiling{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-top:1rem;">
|
||||||
|
<a href="/decks/view?name={{ name|urlencode }}" class="btn" role="button">Back to Deck</a>
|
||||||
|
<a href="/decks" class="btn" role="button">All Decks</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -56,8 +56,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||||
{% if csv_path %}
|
{% if csv_path %}
|
||||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
<form action="/decks/download-csv" method="get" target="_blank" style="display:inline; margin:0;">
|
||||||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
<input type="hidden" name="name" value="{{ name }}" />
|
||||||
<button type="submit">Download CSV</button>
|
<button type="submit">Download CSV</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -68,10 +68,37 @@
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
|
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
|
||||||
|
{% if budget_report %}
|
||||||
|
<a href="/decks/pickups?name={{ name|urlencode }}" class="btn" role="button" title="View cards to acquire for this budget build">Pickups List</a>
|
||||||
|
{% endif %}
|
||||||
<form method="get" action="/decks" style="display:inline; margin:0;">
|
<form method="get" action="/decks" style="display:inline; margin:0;">
|
||||||
<button type="submit">Back to Finished Decks</button>
|
<button type="submit">Back to Finished Decks</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% if budget_report %}
|
||||||
|
{% set bstatus = budget_report.budget_status %}
|
||||||
|
<div class="budget-badge budget-badge--{{ bstatus }}" style="margin-top:.6rem;">
|
||||||
|
{% if bstatus == 'under' %}
|
||||||
|
Under Budget: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||||
|
{% elif bstatus == 'soft_exceeded' %}
|
||||||
|
Over Budget (soft): ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||||
|
(+${{ "%.2f"|format(budget_report.overage) }})
|
||||||
|
{% else %}
|
||||||
|
Hard Cap Exceeded: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||||
|
(+${{ "%.2f"|format(budget_report.overage) }})
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if budget_report.over_budget_cards %}
|
||||||
|
<div class="panel panel-info-warning" style="margin-top:.5rem;">
|
||||||
|
<strong>Cards over budget:</strong>
|
||||||
|
<ul class="muted" style="margin:.25rem 0 0 1rem; padding:0; font-size:.85em;">
|
||||||
|
{% for c in budget_report.over_budget_cards %}
|
||||||
|
<li>{{ c.card }} — ${{ "%.2f"|format(c.price) }}{% if c.ceiling_exceeded %} (above ${{ "%.2f"|format(budget_config.card_ceiling) }} ceiling){% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</aside>
|
</aside>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
|
|
@ -99,6 +126,67 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}
|
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}
|
||||||
|
{# M8: Price charts accordion — placed in main area, only available on the saved deck view #}
|
||||||
|
{% if (price_category_chart and price_category_chart.total > 0) or price_histogram_chart %}
|
||||||
|
<section class="summary-section-lg">
|
||||||
|
<details class="analytics-accordion" id="price-charts-accordion">
|
||||||
|
<summary class="combo-summary">
|
||||||
|
<span>Price Breakdown</span>
|
||||||
|
<span class="muted text-xs font-normal ml-2">spend by category & distribution</span>
|
||||||
|
</summary>
|
||||||
|
<div class="analytics-content mt-3">
|
||||||
|
{% if price_category_chart and price_category_chart.total > 0 %}
|
||||||
|
<div class="price-cat-section">
|
||||||
|
<div class="price-cat-heading">Spend by Category — ${{ '%.2f'|format(price_category_chart.total) }} total</div>
|
||||||
|
<div class="price-cat-bar" title="Total: ${{ '%.2f'|format(price_category_chart.total) }}">
|
||||||
|
{% for cat in price_category_chart.order %}
|
||||||
|
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
|
||||||
|
{% if cat_total > 0 %}
|
||||||
|
{% set pct = (cat_total * 100 / price_category_chart.total) | round(1) %}
|
||||||
|
<div class="price-cat-seg"
|
||||||
|
style="width:{{ pct }}%; background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"
|
||||||
|
title="{{ cat }}: ${{ '%.2f'|format(cat_total) }} ({{ pct }}%)"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="price-cat-legend">
|
||||||
|
{% for cat in price_category_chart.order %}
|
||||||
|
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
|
||||||
|
{% if cat_total > 0 %}
|
||||||
|
<span class="price-cat-legend-item">
|
||||||
|
<span class="price-cat-swatch" style="background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"></span>
|
||||||
|
{{ cat }} ${{ '%.2f'|format(cat_total) }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if price_histogram_chart %}
|
||||||
|
<div class="price-hist-section">
|
||||||
|
<div class="price-hist-heading">Price Distribution</div>
|
||||||
|
<div class="price-hist-bars">
|
||||||
|
{% for bin in price_histogram_chart %}
|
||||||
|
<div class="price-hist-column"
|
||||||
|
data-type="hist"
|
||||||
|
data-range="${{ '%.2f'|format(bin.range_min) }}–${{ '%.2f'|format(bin.range_max) }}"
|
||||||
|
data-val="{{ bin.count }}"
|
||||||
|
data-cards="{% for c in bin.cards %}{{ c.name }}|{{ '%.2f'|format(c.price) }}{% if not loop.last %} • {% endif %}{% endfor %}">
|
||||||
|
<div class="price-hist-bar" style="height:{{ bin.pct | default(0) }}%; background:{{ bin.color }};"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="price-hist-xlabels">
|
||||||
|
{% for bin in price_histogram_chart %}
|
||||||
|
<div class="price-hist-xlabel">{{ bin.x_label }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="muted">No summary available.</div>
|
<div class="muted">No summary available.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
<div id="deck-summary" data-summary>
|
<div id="deck-summary" data-summary>
|
||||||
|
{% if budget_config and budget_config.total %}
|
||||||
|
<script>window._budgetCfg={"total":{{ budget_config.total|float }},"card_ceiling":{{ budget_config.card_ceiling|float if budget_config.card_ceiling else 'null' }}};</script>
|
||||||
|
{% endif %}
|
||||||
<hr class="summary-divider" />
|
<hr class="summary-divider" />
|
||||||
<h4>Deck Summary</h4>
|
<h4>Deck Summary</h4>
|
||||||
<section class="summary-section">
|
<section class="summary-section">
|
||||||
|
|
@ -35,6 +38,9 @@
|
||||||
.owned-flag { font-size:.95rem; opacity:.9; }
|
.owned-flag { font-size:.95rem; opacity:.9; }
|
||||||
</style>
|
</style>
|
||||||
<div id="typeview-list" class="typeview">
|
<div id="typeview-list" class="typeview">
|
||||||
|
{% if budget_config and budget_config.total %}
|
||||||
|
<div id="budget-summary-bar" class="budget-price-bar" aria-live="polite">Loading deck cost...</div>
|
||||||
|
{% endif %}
|
||||||
{% for t in tb.order %}
|
{% for t in tb.order %}
|
||||||
<div class="summary-type-heading">
|
<div class="summary-type-heading">
|
||||||
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||||
|
|
@ -46,7 +52,7 @@
|
||||||
@media (max-width: 1199px) {
|
@media (max-width: 1199px) {
|
||||||
.list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
.list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||||
}
|
}
|
||||||
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) auto 1.6em; align-items:center; column-gap:.45rem; width:100%; }
|
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) auto auto 1.6em; align-items:center; column-gap:.45rem; width:100%; }
|
||||||
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
|
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
|
||||||
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||||
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
@ -75,6 +81,7 @@
|
||||||
<span class="count">{{ cnt }}</span>
|
<span class="count">{{ cnt }}</span>
|
||||||
<span class="times">x</span>
|
<span class="times">x</span>
|
||||||
<span class="name dfc-anchor" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if c.metadata_tags %} data-metadata-tags="{{ (c.metadata_tags|map('trim')|join(', ')) }}"{% endif %}{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}>{{ c.name }}</span>
|
<span class="name dfc-anchor" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if c.metadata_tags %} data-metadata-tags="{{ (c.metadata_tags|map('trim')|join(', ')) }}"{% endif %}{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}>{{ c.name }}</span>
|
||||||
|
<span class="card-price-inline" data-price-for="{{ c.name }}"></span>
|
||||||
<span class="flip-slot" aria-hidden="true">
|
<span class="flip-slot" aria-hidden="true">
|
||||||
{% if c.dfc_land %}
|
{% if c.dfc_land %}
|
||||||
<span class="dfc-land-chip {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC land{% if c.dfc_adds_extra_land %} +1{% endif %}</span>
|
<span class="dfc-land-chip {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC land{% if c.dfc_adds_extra_land %} +1{% endif %}</span>
|
||||||
|
|
@ -114,8 +121,7 @@
|
||||||
<img class="card-thumb" loading="lazy" decoding="async" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
|
<img class="card-thumb" loading="lazy" decoding="async" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
|
||||||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||||||
sizes="(max-width: 1200px) 160px, 240px" />
|
sizes="(max-width: 1200px) 160px, 240px" />
|
||||||
<div class="count-badge">{{ cnt }}x</div>
|
<div class="count-badge">{{ cnt }}x</div> <div class="card-price-overlay" data-price-for="{{ c.name }}" aria-hidden="true"></div> <div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
|
||||||
{% if c.dfc_land %}
|
{% if c.dfc_land %}
|
||||||
<div class="dfc-thumb-badge {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC{% if c.dfc_adds_extra_land %}+1{% endif %}</div>
|
<div class="dfc-thumb-badge {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC{% if c.dfc_adds_extra_land %}+1{% endif %}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -601,6 +607,11 @@
|
||||||
} else if (t === 'curve') {
|
} else if (t === 'curve') {
|
||||||
titleSpan.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
titleSpan.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||||
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||||
|
} else if (t === 'hist') {
|
||||||
|
var hval = el.dataset.val || '0';
|
||||||
|
titleSpan.textContent = (el.dataset.range || '') + ' \u2014 ' + hval + ' card' + (hval !== '1' ? 's' : '');
|
||||||
|
var pairs = (el.dataset.cards || '').split(' \u2022 ').filter(Boolean);
|
||||||
|
listText = pairs.map(function(p){ var idx = p.lastIndexOf('|'); return idx < 0 ? p : p.slice(0, idx) + ' \u2014 $' + parseFloat(p.slice(idx+1)).toFixed(2); }).join('\n');
|
||||||
} else {
|
} else {
|
||||||
titleSpan.textContent = el.getAttribute('aria-label') || '';
|
titleSpan.textContent = el.getAttribute('aria-label') || '';
|
||||||
}
|
}
|
||||||
|
|
@ -662,6 +673,8 @@
|
||||||
var s = String(n);
|
var s = String(n);
|
||||||
// Strip trailing " ×<num>" count suffix if present
|
// Strip trailing " ×<num>" count suffix if present
|
||||||
s = s.replace(/\s×\d+$/,'');
|
s = s.replace(/\s×\d+$/,'');
|
||||||
|
// Strip trailing "|price" suffix from hist bars
|
||||||
|
s = s.replace(/\|[\d.]+$/, '');
|
||||||
return s.trim();
|
return s.trim();
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
@ -707,8 +720,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function attach() {
|
function attach() {
|
||||||
// Attach to SVG elements with data-type for better hover zones
|
// Attach to elements with data-type (SVG mana charts + div hist bars)
|
||||||
document.querySelectorAll('svg[data-type]').forEach(function(el) {
|
document.querySelectorAll('[data-type]').forEach(function(el) {
|
||||||
el.addEventListener('mouseenter', function(e) {
|
el.addEventListener('mouseenter', function(e) {
|
||||||
// Don't show hover tooltip if this element is pinned
|
// Don't show hover tooltip if this element is pinned
|
||||||
if (pinnedEl === el) return;
|
if (pinnedEl === el) return;
|
||||||
|
|
@ -719,7 +732,7 @@
|
||||||
// Cross-highlight for mana curve bars -> card items
|
// Cross-highlight for mana curve bars -> card items
|
||||||
try {
|
try {
|
||||||
var dataType = el.getAttribute('data-type');
|
var dataType = el.getAttribute('data-type');
|
||||||
if (dataType === 'curve' || dataType === 'pips' || dataType === 'sources') {
|
if (dataType === 'curve' || dataType === 'pips' || dataType === 'sources' || dataType === 'hist') {
|
||||||
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
||||||
lastType = dataType;
|
lastType = dataType;
|
||||||
// Only apply hover highlights if nothing is pinned
|
// Only apply hover highlights if nothing is pinned
|
||||||
|
|
@ -769,7 +782,7 @@
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (!pinnedEl) return;
|
if (!pinnedEl) return;
|
||||||
// Don't unpin if clicking the tooltip itself or a chart
|
// Don't unpin if clicking the tooltip itself or a chart
|
||||||
if (tip.contains(e.target) || e.target.closest('svg[data-type]')) return;
|
if (tip.contains(e.target) || e.target.closest('[data-type]')) return;
|
||||||
unpin();
|
unpin();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -825,7 +838,16 @@
|
||||||
}
|
}
|
||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
}
|
}
|
||||||
attach();
|
// On static pages (view.html, run_result.html) deck_summary.html is rendered
|
||||||
|
// before the price-chart histogram bars in the outer template, so the inline
|
||||||
|
// script runs mid-parse and querySelectorAll('[data-type]') would not yet see
|
||||||
|
// those elements. Deferring to DOMContentLoaded fixes this for static pages
|
||||||
|
// while still running immediately when injected via HTMX (readyState 'complete').
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() { attach(); });
|
||||||
|
} else {
|
||||||
|
attach();
|
||||||
|
}
|
||||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,25 @@
|
||||||
<span class="muted" style="align-self:center; font-size:.85rem;">(~15-20 min local, instant if cached on GitHub)</span>
|
<span class="muted" style="align-self:center; font-size:.85rem;">(~15-20 min local, instant if cached on GitHub)</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<details style="margin-top:1.25rem;" open>
|
||||||
|
<summary>Card Price Cache Status</summary>
|
||||||
|
<div style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:var(--panel); border-radius:8px;">
|
||||||
|
<div class="muted">Last updated:</div>
|
||||||
|
<div style="margin-top:.25rem;" id="price-cache-built-at">
|
||||||
|
{% if price_cache_built_at %}{{ price_cache_built_at }}{% else %}<span class="muted">Not yet generated — run Setup first, then refresh prices.</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if price_auto_refresh %}
|
||||||
|
<div class="muted" style="margin-top:.5rem; font-size:.85rem;">Auto-refresh is enabled (runs daily at 01:00 UTC).</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% if not price_auto_refresh %}
|
||||||
|
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||||
|
{{ button('Refresh Card Prices', variant='primary', onclick='refreshPriceCache()', attrs='id="btn-refresh-prices"') }}
|
||||||
|
<span class="muted" style="align-self:center; font-size:.85rem;">Rebuilds price data from local Scryfall bulk data (requires Setup to have run).</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
|
|
@ -620,6 +639,33 @@
|
||||||
setInterval(pollSimilarityStatus, 10000); // Poll every 10s
|
setInterval(pollSimilarityStatus, 10000); // Poll every 10s
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
window.refreshPriceCache = function(){
|
||||||
|
var btn = document.getElementById('btn-refresh-prices');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Refreshing…'; }
|
||||||
|
fetch('/api/price/refresh', { method: 'POST' })
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(data){
|
||||||
|
if (btn) { btn.textContent = 'Refresh started — check logs for progress.'; }
|
||||||
|
// Update timestamp line after a short delay to let the rebuild start
|
||||||
|
setTimeout(function(){
|
||||||
|
fetch('/api/price/stats')
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(s){
|
||||||
|
var el = document.getElementById('price-cache-built-at');
|
||||||
|
if (el && s.last_refresh) {
|
||||||
|
var d = new Date(s.last_refresh * 1000);
|
||||||
|
el.textContent = d.toLocaleDateString('en-US', { year:'numeric', month:'long', day:'numeric' });
|
||||||
|
}
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Refresh Card Prices'; }
|
||||||
|
})
|
||||||
|
.catch(function(){ if (btn) { btn.disabled = false; btn.textContent = 'Refresh Card Prices'; } });
|
||||||
|
}, 3000);
|
||||||
|
})
|
||||||
|
.catch(function(){
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Refresh failed'; setTimeout(function(){ btn.textContent = 'Refresh Card Prices'; }, 2000); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize image status polling
|
// Initialize image status polling
|
||||||
pollImageStatus();
|
pollImageStatus();
|
||||||
setInterval(pollImageStatus, 10000); // Poll every 10s
|
setInterval(pollImageStatus, 10000); // Poll every 10s
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -32,6 +32,11 @@ services:
|
||||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||||
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
|
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
|
||||||
|
ENABLE_BUDGET_MODE: "1" # 1=enable budget mode controls and reporting in the UI
|
||||||
|
BUDGET_POOL_TOLERANCE: "0.15" # Fractional overhead above per-card ceiling before a card is excluded from pool (e.g. 0.15 = 15%)
|
||||||
|
PRICE_AUTO_REFRESH: "0" # 1=rebuild price cache daily at 01:00 UTC
|
||||||
|
PRICE_LAZY_REFRESH: "1" # 1=refresh stale per-card prices in background (7-day TTL)
|
||||||
|
PRICE_STALE_WARNING_HOURS: "24" # Hours before a cached card price is considered stale (shows ⏱ indicator); 0=disable
|
||||||
SHOW_MISC_POOL: "0"
|
SHOW_MISC_POOL: "0"
|
||||||
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
||||||
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)
|
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ services:
|
||||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||||
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
|
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
|
||||||
|
ENABLE_BUDGET_MODE: "1" # 1=enable budget mode controls and reporting in the UI
|
||||||
|
BUDGET_POOL_TOLERANCE: "0.15" # Fractional overhead above per-card ceiling before a card is excluded from pool (e.g. 0.15 = 15%)
|
||||||
|
PRICE_AUTO_REFRESH: "0" # 1=rebuild price cache daily at 01:00 UTC
|
||||||
|
PRICE_LAZY_REFRESH: "1" # 1=refresh stale per-card prices in background (7-day TTL)
|
||||||
|
PRICE_STALE_WARNING_HOURS: "24" # Hours before a cached card price is considered stale (shows ⏱ indicator); 0=disable
|
||||||
SHOW_MISC_POOL: "0"
|
SHOW_MISC_POOL: "0"
|
||||||
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
||||||
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)
|
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue