mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
feat: Added Partners, Backgrounds, and related variation selections to commander building.
This commit is contained in:
parent
641b305955
commit
d416c9b238
65 changed files with 11835 additions and 691 deletions
|
|
@ -45,6 +45,13 @@ WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
|
|||
ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1"
|
||||
WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics fields & /themes/metrics (dev only)
|
||||
|
||||
############################
|
||||
# Partner / Background Mechanics
|
||||
############################
|
||||
ENABLE_PARTNER_MECHANICS=1 # 1=unlock partner/background commander inputs for headless (web wiring in progress)
|
||||
ENABLE_PARTNER_SUGGESTIONS=1 # 1=enable partner suggestion API and UI chips (dataset auto-refreshes when missing)
|
||||
# PARTNER_SUGGESTIONS_DATASET=config/analytics/partner_synergy.json # Optional override path for the suggestion dataset
|
||||
|
||||
############################
|
||||
# Random Modes (alpha)
|
||||
############################
|
||||
|
|
|
|||
50
CHANGELOG.md
50
CHANGELOG.md
|
|
@ -14,7 +14,55 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
### Summary
|
||||
- _TBD_
|
||||
- Partner suggestion service and API power Step 2 suggestion chips for partner, background, and Doctor pairings when `ENABLE_PARTNER_SUGGESTIONS` is active.
|
||||
- Headless runner now honors partner/background inputs behind the `ENABLE_PARTNER_MECHANICS` feature flag and carries regression coverage for dry-run resolution.
|
||||
- Web builder Step 2 exposes partner/background pairing when `ENABLE_PARTNER_MECHANICS` is active, including live previews and warnings for invalid combinations.
|
||||
- Quick-start modal mirrors the Step 2 partner/background controls so fast deck builds can choose a secondary commander or background without leaving the modal.
|
||||
- Partner mechanics UI auto-enables for eligible commanders, renames the secondary picker to “Partner commander,” layers in Partner With defaults with opt-out chips, adds Doctor/Doctor’s Companion pairing, and keeps modal/theme previews in sync.
|
||||
- Deck exports now surface combined commander metadata across CSV/TXT headers and JSON summaries so dual-command builds stay in sync for downstream tooling.
|
||||
|
||||
### Added
|
||||
- Partner suggestion dataset loader, scoring service (`code/web/services/partner_suggestions.py`), FastAPI endpoint, UI chips, dataset override env (`PARTNER_SUGGESTIONS_DATASET`), auto-regeneration when the dataset is missing, and tests covering dataset flattening plus API responses.
|
||||
- CLI regression coverage (`code/tests/test_cli_partner_config.py`) verifying partner/background dry-run payloads and `ENABLE_PARTNER_MECHANICS` env gating in the headless runner.
|
||||
- Web build wizard toggle for partner mechanics with partner/background selectors, auto-pair hints, warnings, and combined color preview behind the feature flag.
|
||||
- Partner and background selections now render card art previews (with Scryfall links) in the quick-start wizard, Step 2 form, and deck summary so builders can confirm the secondary pick at a glance.
|
||||
- Quick-start modal now renders shared partner/background controls (reusing `_partner_controls.html`) whenever a commander that supports the mechanic is inspected.
|
||||
- Background catalog loader (`code/deck_builder/background_loader.py`) with memoized parsing, typed entries, and a generator utility (`python -m code.scripts.generate_background_cards`) plus coverage to ensure only legal backgrounds enter the catalog.
|
||||
- Shared `CombinedCommander` aggregation and partner/background selection helper wired through deck builds, exports, and partner preview endpoints with accompanying regression tests.
|
||||
- Script `python -m code.scripts.build_partner_suggestions` materializes commander metadata, theme indexes, and observed pairings into `config/analytics/partner_synergy.json` to seed the partner suggestion engine.
|
||||
- Partner suggestion scoring helper (`code/deck_builder/suggestions.py`) with mode-specific weights and regression tests ensuring canonical pairings rank highest across partner, background, and Doctor flows.
|
||||
- Export regression coverage (`code/tests/test_export_commander_metadata.py`) verifies commander metadata is embedded in CSV/TXT headers and summary payloads while preserving existing columns.
|
||||
- Partner suggestion telemetry emits `partner_suggestions.generated` and `partner_suggestions.selected` logs (via `code/web/services/telemetry.py`) so adoption metrics and dataset diagnostics can be monitored.
|
||||
|
||||
### Changed
|
||||
- Partner controls hydrate suggestion chips on the web builder and quick-start modal, fetching ranked partner/backdrop recommendations while respecting active partner mode and session locks when `ENABLE_PARTNER_SUGGESTIONS=1`.
|
||||
- Partner suggestion scoring now filters out broad "Legends Matter", "Historics Matter", and Kindred themes when computing overlap or synergy so recommendations emphasize distinctive commander pairings.
|
||||
- Headless runner parsing now resolves `--secondary-commander` and `--background` inputs (mutually exclusive), applies the shared partner selection helper ahead of deck assembly, and surfaces flag-controlled behavior in exported dry-run payloads.
|
||||
- Step 2 submission now validates partner inputs, stores combined commander previews/warnings in the session, and clears prior partner state when the toggle is disabled.
|
||||
- Quick-start `/build/new` submission resolves partner selections, persists the combined commander payload, and re-renders the modal with inline partner errors when inputs conflict.
|
||||
- Partner controls mount automatically for eligible commanders, replace the manual toggle with a hidden enable flag, rename the select to “Partner commander,” and expose an opt-out chip when Partner With suggests a default.
|
||||
- Commander catalog metadata now flags Doctors and Doctor’s Companions so selectors present only legal pairings and annotate each option with its role.
|
||||
- Partner detection now distinguishes the standalone “Partner” keyword from Partner With/Doctor’s Companion/restricted variants, and the web selector filters plain-partner pools to exclude those mechanics while keeping direct Partner With pairings intact.
|
||||
- Structured partner selection logs now emit `partner_mode_selected` with commander color deltas, capturing colors before and after pairing for diagnostics parity.
|
||||
- Structured partner selection logs now tag suggestion-driven selections with a `selection_source` attribute to differentiate manual picks from suggestion chip adoption.
|
||||
- Commander setup now regenerates `background_cards.csv` alongside `commander_cards.csv`, ensuring the background picker stays synchronized after catalog refreshes or fresh installs.
|
||||
- Setup/tagging auto-refresh now runs the partner suggestion dataset builder so `config/analytics/partner_synergy.json` tracks the latest commander catalog and deck exports without manual scripts.
|
||||
- CSV/TXT deck exports append commander metadata columns, text headers include partner mode and colors, and summary sidecars embed serialized combined commander details without breaking legacy consumers.
|
||||
- Partner commander previews in Step 2 and the build summary now mirror the primary commander card layout (including hover metadata and high-res art) so both selections share identical interactions.
|
||||
|
||||
### Fixed
|
||||
- Regenerated `background_cards.csv` and tightened background detection so the picker only lists true Background enchantments, preventing "Choose a Background" commanders from appearing as illegal partners and restoring background availability when the CSV was missing.
|
||||
- Restricted partner commanders with dash-based keywords (e.g., Partner - Survivors, Partner - Father & Son) now register as partners and surface their matching group pairings in the web selector.
|
||||
- Quick-start modal partner previews now merge theme tags with Step 2 so chips stay consistent after commander inspection.
|
||||
- Step 5 summary and quick-start commander preview now surface merged partner color identity and theme tags so pairings like Halana + Alena display both colors.
|
||||
- Partner and background builds now inject the secondary commander card automatically, keeping deck libraries, exports, and Step 5 summaries in sync with the chosen pairing.
|
||||
- Partner With commanders now restrict the dropdown to their canon companion and the preview panel adopts the wizard theme colors for better readability while live-selection previews render immediately.
|
||||
- Manual partner selections now persist across the wizard and quick-start modal, keeping recommendations and theme chips in sync without needing an extra apply step.
|
||||
- Background picker now falls back to the commander catalog when `background_cards.csv` is missing so “Choose a Background” commanders remain selectable in the web UI.
|
||||
- Partner hover previews now respect the secondary commander data so the popup matches the card you’re focusing.
|
||||
- Step 5 summary and finished deck views now surface the deck’s chosen themes (and commander hover metadata) without flooding the UI with every commander tag.
|
||||
- Doctor’s Companion commanders now surface only legal Doctor pairings, direct Partner With matches (e.g., Amy & Rory) remain available, and escaped newline text no longer breaks partner detection.
|
||||
- Partner suggestion refresh now re-attempts dataset generation when triggered from the UI and ensures the builder script loads project packages inside Docker, so missing `partner_synergy.json` files can be recreated without restarting the web app.
|
||||
|
||||
## [2.4.1] - 2025-10-03
|
||||
### Summary
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ docker compose run --rm `
|
|||
- `APP_MODE=cli` routes the entrypoint to the CLI menu.
|
||||
- `DECK_MODE=headless` skips prompts and calls `headless_runner`.
|
||||
- Mount JSON configs under `config/` so both the UI and CLI can pick them up.
|
||||
- Dual-commander support is feature-flagged: set `ENABLE_PARTNER_MECHANICS=1` and pass `--secondary-commander` _or_ `--background` (mutually exclusive) to layer partners/backgrounds into headless runs; Partner With and Doctor/Doctor’s Companion pairings auto-resolve (with opt-out), and `--dry-run` echoes the resolved pairing for verification.
|
||||
- Partner suggestions share the same dataset for headless and web flows; set `ENABLE_PARTNER_SUGGESTIONS=1` (and ensure `config/analytics/partner_synergy.json` exists) to expose ranked pairings in the UI and API.
|
||||
|
||||
Override counts, theme tags, or include/exclude lists by setting the matching environment variables before running the container (see “Environment variables” below).
|
||||
|
||||
|
|
@ -232,6 +234,13 @@ See `.env.example` for the full catalog. Common knobs:
|
|||
| `DECK_CONFIG` | `/app/config/deck.json` | JSON config file or directory (auto-discovery). |
|
||||
| `HOST` / `PORT` / `WORKERS` | `0.0.0.0` / `8080` / `1` | Uvicorn binding when `APP_MODE=web`. |
|
||||
|
||||
### Partner mechanics & suggestions
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ENABLE_PARTNER_MECHANICS` | `0` | Unlock partner/background commander inputs for headless runs and Step 2 of the web UI. |
|
||||
| `ENABLE_PARTNER_SUGGESTIONS` | `0` | Serve partner/background/Doctor suggestion chips based on `config/analytics/partner_synergy.json` (auto-regenerated when missing; override path with `PARTNER_SUGGESTIONS_DATASET`). |
|
||||
|
||||
### Homepage visibility & UX
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -79,8 +79,14 @@ Every tile on the homepage connects to a workflow. Use these sections as your to
|
|||
Start here for interactive deck creation.
|
||||
- Pick commander, themes (primary/secondary/tertiary), bracket, and optional deck name in the unified modal.
|
||||
- Add supplemental themes in the **Additional Themes** section (ENABLE_CUSTOM_THEMES): fuzzy suggestions, removable chips, and strict/permissive matching toggles respect `THEME_MATCH_MODE` and `USER_THEME_LIMIT`.
|
||||
- Partner mechanics (ENABLE_PARTNER_MECHANICS): Step 2 and the quick-start modal auto-enable partner controls for eligible commanders, show only legal partner/background/Doctor options, and keep previews, warnings, and theme chips in sync.
|
||||
- Partner suggestions (ENABLE_PARTNER_SUGGESTIONS): ranked chips appear beside the partner selector, recommending popular partner/background/Doctor pairings based on the analytics dataset; selections respect existing partner mode and lock states.
|
||||
- Partner: pick a second commander from the filtered dropdown labeled “Partner commander”; the background picker clears automatically.
|
||||
- Partner With: the canonical partner pre-fills and surfaces an opt-out chip so you can keep or swap the suggestion.
|
||||
- Doctor / Doctor’s Companion: Doctors list legal companions (and vice versa) with role labels, and the opt-out chip mirrors Partner With behavior.
|
||||
- Background: choose a Background instead of a second commander; partner selectors hide when not applicable.
|
||||
- Locks, Replace, Compare, and Permalinks live in Step 5.
|
||||
- Exports (CSV, TXT, compliance JSON, summary JSON) land in `deck_files/` and reuse your chosen deck name when set.
|
||||
- Exports (CSV, TXT, compliance JSON, summary JSON) land in `deck_files/` and reuse your chosen deck name when set. CSV/TXT headers now include commander metadata (names, partner mode, colors) so downstream tools can pick up dual-commander context without extra parsing.
|
||||
- `ALLOW_MUST_HAVES=1` (default) enables include/exclude enforcement.
|
||||
- `WEB_AUTO_ENFORCE=1` re-runs bracket enforcement automatically after each build.
|
||||
|
||||
|
|
@ -167,6 +173,7 @@ The CLI and headless runners share the builder core.
|
|||
- Run headless (non-interactive) builds: `python code/headless_runner.py --config config/deck.json`.
|
||||
- In Docker, set `APP_MODE=cli` (and optionally `DECK_MODE=headless`) to switch the container entrypoint to the CLI.
|
||||
- Config precedence is CLI prompts > environment variables > JSON config > defaults.
|
||||
- Dual-commander support (feature-flagged): `--secondary-commander` or `--background` (mutually exclusive) can be supplied alongside `--enable-partner-mechanics true` or `ENABLE_PARTNER_MECHANICS=1`; Partner With and Doctor/Doctor’s Companion pairings auto-resolve (respecting opt-outs), dry runs echo the resolved pairing, and JSON configs may include `secondary_commander`, `background`, and `enable_partner_mechanics` keys.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -179,7 +186,7 @@ The CLI and headless runners share the builder core.
|
|||
| `config/` | `/app/config` | JSON configs, bracket policies, themes, card lists |
|
||||
| `owned_cards/` | `/app/owned_cards` | Uploaded owned-card libraries |
|
||||
|
||||
Exports follow a stable naming scheme and include a `.summary.json` sidecar containing deck metadata, resolved themes, and lock history.
|
||||
Exports follow a stable naming scheme and include a `.summary.json` sidecar containing deck metadata, resolved themes, combined commander payloads, and lock history.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -194,6 +201,12 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
|
|||
| `DECK_CONFIG` | `/app/config/deck.json` | Points the headless runner at a config file or folder. |
|
||||
| `HOST` / `PORT` / `WORKERS` | `0.0.0.0` / `8080` / `1` | Uvicorn settings for the web server. |
|
||||
|
||||
### Partner / Background mechanics (feature-flagged)
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ENABLE_PARTNER_MECHANICS` | `0` | Unlock partner/background commander inputs for headless runs and the web builder Step 2 UI. |
|
||||
| `ENABLE_PARTNER_SUGGESTIONS` | `0` | Surface partner/background/Doctor suggestion chips backed by `config/analytics/partner_synergy.json` (auto-regenerated when missing; override path with `PARTNER_SUGGESTIONS_DATASET`). |
|
||||
|
||||
### Homepage visibility & UX
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
|
|
|
|||
|
|
@ -1,13 +1,38 @@
|
|||
# MTG Python Deckbuilder ${VERSION}
|
||||
|
||||
## Summary
|
||||
- _TBD_
|
||||
- Partner suggestion service and UI chips recommend secondary commanders (partner/background/Doctor) when `ENABLE_PARTNER_SUGGESTIONS` is enabled.
|
||||
- Headless runner now honors partner/background inputs behind the `ENABLE_PARTNER_MECHANICS` feature flag and exposes the resolved configuration in dry-run output.
|
||||
- Web builder Step 2 displays partner/background pairing controls (toggle, selectors, preview, warnings) when the feature flag is active.
|
||||
- Quick-start modal now embeds the shared partner/background controls so the rapid flow can choose secondary commanders or backgrounds without leaving the overlay.
|
||||
- Partner mechanics UI auto-enables for eligible commanders, renames the selector to “Partner commander,” layers in Partner With defaults with an opt-out chip, and adds Doctor/Doctor’s Companion pairing coverage while keeping theme tags consistent across modal and Step 2.
|
||||
- Background catalog parsing is now centralized in `load_background_cards()` with typed entries, memoized caching, and a generator utility so background-only card lists stay fresh.
|
||||
- Commander setup now regenerates `background_cards.csv` whenever commander catalogs refresh, keeping background pickers aligned after setup or data updates.
|
||||
|
||||
## Added
|
||||
- _TBD_
|
||||
- Partner suggestion dataset loader, FastAPI endpoint, UI wiring, dataset override env (`PARTNER_SUGGESTIONS_DATASET`), automatic regeneration when the dataset is missing, and regression coverage for ranked results when suggestions are enabled.
|
||||
- CLI regression coverage (`code/tests/test_cli_partner_config.py`) validating partner/background dry-run payloads and environment flag precedence.
|
||||
- Partner mechanics UI in the web builder (Step 2) with live preview, warnings, and automatic Partner With hints behind `ENABLE_PARTNER_MECHANICS`.
|
||||
- Quick-start modal renders the `_partner_controls.html` partial, surfacing partner/background selections during commander inspection.
|
||||
- Commander metadata now flags Doctors and Doctor’s Companions, enabling legal doctor/companion pairings in partner selectors with role-aware labels.
|
||||
- New background catalog loader and `python -m code.scripts.generate_background_cards` utility, plus regression coverage ensuring only legal backgrounds populate the catalog.
|
||||
- Shared `build_combined_commander()` aggregation and partner selection helper reused by headless, web, and orchestration flows with expanded unit coverage.
|
||||
|
||||
## Changed
|
||||
- _TBD_
|
||||
- Partner controls now fetch suggestion chips in Step 2 and the quick-start modal (respecting partner mode and locks) when `ENABLE_PARTNER_SUGGESTIONS=1`.
|
||||
- Partner suggestion scoring filters out broad "Legends Matter", "Historics Matter", and Kindred themes during overlap/synergy calculations so suggested pairings highlight distinctive commander synergies.
|
||||
- Headless runner parsing resolves `--secondary-commander` and `--background` inputs (mutually exclusive), applies the partner selection helper before deck assembly, and surfaces partner metadata when the feature flag is enabled.
|
||||
- Step 2 submission now validates partner selections, stores combined commander previews in session state, and clears partner context when the toggle is disabled.
|
||||
- `/build/new` submission mirrors the partner validation/resolution flow, persisting combined commander payloads and returning inline partner errors when inputs conflict.
|
||||
- Partner controls no longer rely on a manual checkbox; they render automatically for eligible commanders, rename the secondary selector to “Partner commander,” and expose a Partner With default chip that can be toggled off.
|
||||
- Deck assembly, exports, and preview endpoints now consume the shared combined-commander payload so color identity, theme tags, and warnings stay aligned across flows.
|
||||
- Partner detection differentiates between standalone “Partner” cards and restricted mechanics (Partner With, Doctor’s Companion, hyphenated variants), keeping plain-partner pools clean while retaining direct Partner With pairings.
|
||||
- Structured partner selection logs now emit `partner_mode_selected` and include before/after color identity snapshots to support diagnostics and telemetry dashboards.
|
||||
- Commander setup now regenerates the background catalog in the same pass as commander CSVs, so downstream pickers stay synchronized without manual scripts.
|
||||
|
||||
## Fixed
|
||||
- _TBD_
|
||||
- Regenerated `background_cards.csv` and refined detection so only true Background enchantments appear in the dropdown, preventing "Choose a Background" commanders from showing up as illegal selections.
|
||||
- Quick-start modal now mirrors Step 2’s merged theme tags so chips stay consistent after commander inspection.
|
||||
- Step 5 summary and quick-start commander preview now surface merged partner color identity and theme tags so partnered commanders show the full color pair.
|
||||
- Background picker falls back to the commander catalog when `background_cards.csv` is missing so “Choose a Background” commanders keep their pairing options in the web UI.
|
||||
- Partner suggestions refresh actions now retry dataset generation and load the builder script with the correct project path, allowing missing `partner_synergy.json` files to be rebuilt without restarting the web service.
|
||||
|
|
|
|||
262
code/deck_builder/background_loader.py
Normal file
262
code/deck_builder/background_loader.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"""Loader for background cards derived from `background_cards.csv`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Mapping, Tuple
|
||||
|
||||
from code.logging_util import get_logger
|
||||
from deck_builder.partner_background_utils import analyze_partner_background
|
||||
from path_util import csv_dir
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
BACKGROUND_FILENAME = "background_cards.csv"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BackgroundCard:
|
||||
"""Normalized background card entry."""
|
||||
|
||||
name: str
|
||||
face_name: str | None
|
||||
display_name: str
|
||||
slug: str
|
||||
color_identity: Tuple[str, ...]
|
||||
colors: Tuple[str, ...]
|
||||
mana_cost: str
|
||||
mana_value: float | None
|
||||
type_line: str
|
||||
oracle_text: str
|
||||
keywords: Tuple[str, ...]
|
||||
theme_tags: Tuple[str, ...]
|
||||
raw_theme_tags: Tuple[str, ...]
|
||||
edhrec_rank: int | None
|
||||
layout: str
|
||||
side: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BackgroundCatalog:
|
||||
source_path: Path
|
||||
etag: str
|
||||
mtime_ns: int
|
||||
size: int
|
||||
version: str
|
||||
entries: Tuple[BackgroundCard, ...]
|
||||
by_name: Mapping[str, BackgroundCard]
|
||||
|
||||
def get(self, name: str) -> BackgroundCard | None:
|
||||
return self.by_name.get(name.lower())
|
||||
|
||||
|
||||
def load_background_cards(
|
||||
source_path: str | Path | None = None,
|
||||
) -> BackgroundCatalog:
|
||||
"""Load and cache background card data."""
|
||||
|
||||
resolved = _resolve_background_path(source_path)
|
||||
try:
|
||||
stat = resolved.stat()
|
||||
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
|
||||
size = stat.st_size
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"Background CSV not found at {resolved}") from None
|
||||
|
||||
entries, version = _load_background_cards_cached(str(resolved), mtime_ns)
|
||||
etag = f"{size}-{mtime_ns}-{len(entries)}"
|
||||
catalog = BackgroundCatalog(
|
||||
source_path=resolved,
|
||||
etag=etag,
|
||||
mtime_ns=mtime_ns,
|
||||
size=size,
|
||||
version=version,
|
||||
entries=entries,
|
||||
by_name={card.display_name.lower(): card for card in entries},
|
||||
)
|
||||
LOGGER.info("background_cards_loaded count=%s version=%s path=%s", len(entries), version, resolved)
|
||||
return catalog
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[BackgroundCard, ...], str]:
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
return tuple(), "unknown"
|
||||
|
||||
with path.open("r", encoding="utf-8", newline="") as handle:
|
||||
first_line = handle.readline()
|
||||
version = "unknown"
|
||||
if first_line.startswith("#"):
|
||||
version = _parse_version(first_line)
|
||||
else:
|
||||
handle.seek(0)
|
||||
reader = csv.DictReader(handle)
|
||||
if reader.fieldnames is None:
|
||||
return tuple(), version
|
||||
entries = _rows_to_cards(reader)
|
||||
|
||||
frozen = tuple(entries)
|
||||
return frozen, version
|
||||
|
||||
|
||||
def _resolve_background_path(override: str | Path | None) -> Path:
|
||||
if override:
|
||||
return Path(override).resolve()
|
||||
return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve()
|
||||
|
||||
|
||||
def _parse_version(line: str) -> str:
|
||||
tokens = line.lstrip("# ").strip().split()
|
||||
for token in tokens:
|
||||
if "=" not in token:
|
||||
continue
|
||||
key, value = token.split("=", 1)
|
||||
if key == "version":
|
||||
return value
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
|
||||
entries: list[BackgroundCard] = []
|
||||
seen: set[str] = set()
|
||||
for raw in reader:
|
||||
if not raw:
|
||||
continue
|
||||
card = _row_to_card(raw)
|
||||
if card is None:
|
||||
continue
|
||||
key = card.display_name.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
entries.append(card)
|
||||
entries.sort(key=lambda card: card.display_name)
|
||||
return entries
|
||||
|
||||
|
||||
def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None:
|
||||
name = _clean_str(row.get("name"))
|
||||
face_name = _clean_str(row.get("faceName")) or None
|
||||
display = face_name or name
|
||||
if not display:
|
||||
return None
|
||||
|
||||
type_line = _clean_str(row.get("type"))
|
||||
oracle_text = _clean_multiline(row.get("text"))
|
||||
raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags")))
|
||||
detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags)
|
||||
if not detection.is_background:
|
||||
return None
|
||||
|
||||
return BackgroundCard(
|
||||
name=name,
|
||||
face_name=face_name,
|
||||
display_name=display,
|
||||
slug=_slugify(display),
|
||||
color_identity=_parse_color_list(row.get("colorIdentity")),
|
||||
colors=_parse_color_list(row.get("colors")),
|
||||
mana_cost=_clean_str(row.get("manaCost")),
|
||||
mana_value=_parse_float(row.get("manaValue")),
|
||||
type_line=type_line,
|
||||
oracle_text=oracle_text,
|
||||
keywords=tuple(_split_list(row.get("keywords"))),
|
||||
theme_tags=tuple(tag for tag in raw_theme_tags if tag),
|
||||
raw_theme_tags=raw_theme_tags,
|
||||
edhrec_rank=_parse_int(row.get("edhrecRank")),
|
||||
layout=_clean_str(row.get("layout")) or "normal",
|
||||
side=_clean_str(row.get("side")) or None,
|
||||
)
|
||||
|
||||
|
||||
def _clean_str(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _clean_multiline(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
||||
return "\n".join(line.rstrip() for line in text.splitlines())
|
||||
|
||||
|
||||
def _parse_literal_list(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
parsed = ast.literal_eval(text)
|
||||
except Exception:
|
||||
parsed = None
|
||||
if isinstance(parsed, (list, tuple, set)):
|
||||
return [str(item).strip() for item in parsed if str(item).strip()]
|
||||
parts = [part.strip() for part in text.replace(";", ",").split(",")]
|
||||
return [part for part in parts if part]
|
||||
|
||||
|
||||
def _split_list(value: object) -> list[str]:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return []
|
||||
parts = [part.strip() for part in text.split(",")]
|
||||
return [part for part in parts if part]
|
||||
|
||||
|
||||
def _parse_color_list(value: object) -> Tuple[str, ...]:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return tuple()
|
||||
parts = [part.strip().upper() for part in text.split(",")]
|
||||
return tuple(part for part in parts if part)
|
||||
|
||||
|
||||
def _parse_float(value: object) -> float | None:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int(value: object) -> int | None:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return int(float(text))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
lowered = value.strip().lower()
|
||||
allowed = [ch if ch.isalnum() else "-" for ch in lowered]
|
||||
slug = "".join(allowed)
|
||||
slug = re.sub(r"-+", "-", slug)
|
||||
return slug.strip("-")
|
||||
|
||||
|
||||
def clear_background_cards_cache() -> None:
|
||||
"""Clear the memoized background card cache (testing/support)."""
|
||||
|
||||
_load_background_cards_cached.cache_clear()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BackgroundCard",
|
||||
"BackgroundCatalog",
|
||||
"clear_background_cards_cache",
|
||||
"load_background_cards",
|
||||
]
|
||||
|
|
@ -1054,26 +1054,40 @@ class DeckBuilder(
|
|||
if self.commander_row is None:
|
||||
raise RuntimeError("Commander must be selected before determining color identity.")
|
||||
|
||||
raw_ci = self.commander_row.get('colorIdentity')
|
||||
if isinstance(raw_ci, list):
|
||||
colors_list = raw_ci
|
||||
elif isinstance(raw_ci, str) and raw_ci.strip():
|
||||
# Could be formatted like "['B','G']" or 'BG'; attempt simple parsing
|
||||
if ',' in raw_ci:
|
||||
colors_list = [c.strip().strip("'[] ") for c in raw_ci.split(',') if c.strip().strip("'[] ")]
|
||||
else:
|
||||
colors_list = [c for c in raw_ci if c.isalpha()]
|
||||
override_identity = getattr(self, 'combined_color_identity', None)
|
||||
colors_list: List[str]
|
||||
if override_identity:
|
||||
colors_list = [str(c).strip().upper() for c in override_identity if str(c).strip()]
|
||||
else:
|
||||
# Fallback to 'colors' field or treat as colorless
|
||||
alt = self.commander_row.get('colors')
|
||||
if isinstance(alt, list):
|
||||
colors_list = alt
|
||||
elif isinstance(alt, str) and alt.strip():
|
||||
colors_list = [c for c in alt if c.isalpha()]
|
||||
raw_ci = self.commander_row.get('colorIdentity')
|
||||
if isinstance(raw_ci, list):
|
||||
colors_list = [str(c).strip().upper() for c in raw_ci]
|
||||
elif isinstance(raw_ci, str) and raw_ci.strip():
|
||||
# Could be formatted like "['B','G']" or 'BG'; attempt simple parsing
|
||||
if ',' in raw_ci:
|
||||
colors_list = [c.strip().strip("'[] ").upper() for c in raw_ci.split(',') if c.strip().strip("'[] ")]
|
||||
else:
|
||||
colors_list = [c.upper() for c in raw_ci if c.isalpha()]
|
||||
else:
|
||||
colors_list = []
|
||||
# Fallback to 'colors' field or treat as colorless
|
||||
alt = self.commander_row.get('colors')
|
||||
if isinstance(alt, list):
|
||||
colors_list = [str(c).strip().upper() for c in alt]
|
||||
elif isinstance(alt, str) and alt.strip():
|
||||
colors_list = [c.upper() for c in alt if c.isalpha()]
|
||||
else:
|
||||
colors_list = []
|
||||
|
||||
self.color_identity = [c.upper() for c in colors_list]
|
||||
deduped: List[str] = []
|
||||
seen_tokens: set[str] = set()
|
||||
for token in colors_list:
|
||||
if not token:
|
||||
continue
|
||||
if token not in seen_tokens:
|
||||
seen_tokens.add(token)
|
||||
deduped.append(token)
|
||||
|
||||
self.color_identity = deduped
|
||||
self.color_identity_key = self._canonical_color_key(self.color_identity)
|
||||
|
||||
# Match against maps
|
||||
|
|
@ -1097,6 +1111,14 @@ class DeckBuilder(
|
|||
|
||||
self.color_identity_full = full
|
||||
self.files_to_load = load_files
|
||||
|
||||
# Synchronize commander summary metadata when partner overrides are present
|
||||
if override_identity and self.commander_dict:
|
||||
try:
|
||||
self.commander_dict["Color Identity"] = list(self.color_identity)
|
||||
self.commander_dict["Colors"] = list(self.color_identity)
|
||||
except Exception:
|
||||
pass
|
||||
return full, load_files
|
||||
|
||||
def setup_dataframes(self) -> pd.DataFrame:
|
||||
|
|
|
|||
|
|
@ -512,7 +512,7 @@ DEFAULT_THEME_TAGS = [
|
|||
'Enter the Battlefield', 'Equipment', 'Exile Matters', 'Infect',
|
||||
'Interaction', 'Lands Matter', 'Leave the Battlefield', 'Legends Matter',
|
||||
'Life Matters', 'Mill', 'Monarch', 'Protection', 'Ramp', 'Reanimate',
|
||||
'Removal', 'Sacrifice Matters', 'Spellslinger', 'Stax', 'Super Friends',
|
||||
'Removal', 'Sacrifice Matters', 'Spellslinger', 'Stax', 'Superfriends',
|
||||
'Theft', 'Token Creation', 'Tokens Matter', 'Voltron', 'X Spells'
|
||||
]
|
||||
|
||||
|
|
|
|||
134
code/deck_builder/color_identity_utils.py
Normal file
134
code/deck_builder/color_identity_utils.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Utilities for working with Magic color identity tuples and labels."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, List
|
||||
|
||||
__all__ = [
|
||||
"canon_color_code",
|
||||
"format_color_label",
|
||||
"color_label_from_code",
|
||||
"normalize_colors",
|
||||
]
|
||||
|
||||
_WUBRG_ORDER: tuple[str, ...] = ("W", "U", "B", "R", "G")
|
||||
_VALID_COLORS: frozenset[str] = frozenset((*_WUBRG_ORDER, "C"))
|
||||
_COLOR_NAMES: dict[str, str] = {
|
||||
"W": "White",
|
||||
"U": "Blue",
|
||||
"B": "Black",
|
||||
"R": "Red",
|
||||
"G": "Green",
|
||||
"C": "Colorless",
|
||||
}
|
||||
_TWO_COLOR_LABELS: dict[str, str] = {
|
||||
"WU": "Azorius",
|
||||
"UB": "Dimir",
|
||||
"BR": "Rakdos",
|
||||
"RG": "Gruul",
|
||||
"WG": "Selesnya",
|
||||
"WB": "Orzhov",
|
||||
"UR": "Izzet",
|
||||
"BG": "Golgari",
|
||||
"WR": "Boros",
|
||||
"UG": "Simic",
|
||||
}
|
||||
_THREE_COLOR_LABELS: dict[str, str] = {
|
||||
"WUB": "Esper",
|
||||
"UBR": "Grixis",
|
||||
"BRG": "Jund",
|
||||
"WRG": "Naya",
|
||||
"WUG": "Bant",
|
||||
"WBR": "Mardu",
|
||||
"WUR": "Jeskai",
|
||||
"UBG": "Sultai",
|
||||
"URG": "Temur",
|
||||
"WBG": "Abzan",
|
||||
}
|
||||
_FOUR_COLOR_LABELS: dict[str, str] = {
|
||||
"WUBR": "Yore-Tiller",
|
||||
"WUBG": "Witch-Maw",
|
||||
"WURG": "Ink-Treader",
|
||||
"WBRG": "Dune-Brood",
|
||||
"UBRG": "Glint-Eye",
|
||||
}
|
||||
|
||||
|
||||
def _extract_tokens(identity: Iterable[str] | str | None) -> List[str]:
|
||||
if identity is None:
|
||||
return []
|
||||
tokens: list[str] = []
|
||||
if isinstance(identity, str):
|
||||
identity_iter: Iterable[str] = (identity,)
|
||||
else:
|
||||
identity_iter = identity
|
||||
for item in identity_iter:
|
||||
if item is None:
|
||||
continue
|
||||
text = str(item).strip().upper()
|
||||
if not text:
|
||||
continue
|
||||
if len(text) > 1 and text.isalpha():
|
||||
for ch in text:
|
||||
if ch in _VALID_COLORS:
|
||||
tokens.append(ch)
|
||||
else:
|
||||
for ch in text:
|
||||
if ch in _VALID_COLORS:
|
||||
tokens.append(ch)
|
||||
return tokens
|
||||
|
||||
|
||||
def normalize_colors(identity: Iterable[str] | str | None) -> list[str]:
|
||||
tokens = _extract_tokens(identity)
|
||||
if not tokens:
|
||||
return []
|
||||
seen: set[str] = set()
|
||||
collected: list[str] = []
|
||||
for token in tokens:
|
||||
if token in _WUBRG_ORDER and token not in seen:
|
||||
seen.add(token)
|
||||
collected.append(token)
|
||||
return [color for color in _WUBRG_ORDER if color in seen]
|
||||
|
||||
|
||||
def canon_color_code(identity: Iterable[str] | str | None) -> str:
|
||||
tokens = _extract_tokens(identity)
|
||||
if not tokens:
|
||||
return "C"
|
||||
ordered = [color for color in _WUBRG_ORDER if color in tokens]
|
||||
if ordered:
|
||||
return "".join(ordered)
|
||||
if "C" in tokens:
|
||||
return "C"
|
||||
return "C"
|
||||
|
||||
|
||||
def color_label_from_code(code: str) -> str:
|
||||
if not code:
|
||||
return ""
|
||||
if code == "C":
|
||||
return "Colorless (C)"
|
||||
if len(code) == 1:
|
||||
base = _COLOR_NAMES.get(code, code)
|
||||
return f"{base} ({code})"
|
||||
if len(code) == 2:
|
||||
label = _TWO_COLOR_LABELS.get(code)
|
||||
if label:
|
||||
return f"{label} ({code})"
|
||||
if len(code) == 3:
|
||||
label = _THREE_COLOR_LABELS.get(code)
|
||||
if label:
|
||||
return f"{label} ({code})"
|
||||
if len(code) == 4:
|
||||
label = _FOUR_COLOR_LABELS.get(code)
|
||||
if label:
|
||||
return f"{label} ({code})"
|
||||
if code == "WUBRG":
|
||||
return "Five-Color (WUBRG)"
|
||||
parts = [_COLOR_NAMES.get(ch, ch) for ch in code]
|
||||
pretty = " / ".join(parts)
|
||||
return f"{pretty} ({code})"
|
||||
|
||||
|
||||
def format_color_label(identity: Iterable[str] | str | None) -> str:
|
||||
return color_label_from_code(canon_color_code(identity))
|
||||
325
code/deck_builder/combined_commander.py
Normal file
325
code/deck_builder/combined_commander.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"""Combine commander selections across partner/background modes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Iterable, Sequence, Tuple
|
||||
|
||||
from exceptions import CommanderPartnerError
|
||||
|
||||
from code.deck_builder.partner_background_utils import analyze_partner_background
|
||||
from code.deck_builder.color_identity_utils import canon_color_code, color_label_from_code
|
||||
|
||||
_WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G", "C")
|
||||
_COLOR_PRIORITY = {color: index for index, color in enumerate(_WUBRG_ORDER)}
|
||||
|
||||
|
||||
class PartnerMode(str, Enum):
|
||||
"""Enumerates supported partner mechanics."""
|
||||
|
||||
NONE = "none"
|
||||
PARTNER = "partner"
|
||||
PARTNER_WITH = "partner_with"
|
||||
BACKGROUND = "background"
|
||||
DOCTOR_COMPANION = "doctor_companion"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CombinedCommander:
|
||||
"""Represents merged commander metadata for deck building."""
|
||||
|
||||
primary_name: str
|
||||
secondary_name: str | None
|
||||
partner_mode: PartnerMode
|
||||
color_identity: Tuple[str, ...]
|
||||
theme_tags: Tuple[str, ...]
|
||||
raw_tags_primary: Tuple[str, ...]
|
||||
raw_tags_secondary: Tuple[str, ...]
|
||||
warnings: Tuple[str, ...]
|
||||
color_code: str = ""
|
||||
color_label: str = ""
|
||||
primary_color_identity: Tuple[str, ...] = ()
|
||||
secondary_color_identity: Tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CommanderData:
|
||||
name: str
|
||||
display_name: str
|
||||
color_identity: Tuple[str, ...]
|
||||
themes: Tuple[str, ...]
|
||||
raw_tags: Tuple[str, ...]
|
||||
partner_with: Tuple[str, ...]
|
||||
is_partner: bool
|
||||
supports_backgrounds: bool
|
||||
is_background: bool
|
||||
is_doctor: bool
|
||||
is_doctors_companion: bool
|
||||
|
||||
@classmethod
|
||||
def from_source(cls, source: object) -> "_CommanderData":
|
||||
name = _get_attr(source, "name") or _get_attr(source, "display_name") or ""
|
||||
display_name = _get_attr(source, "display_name") or name
|
||||
if not display_name:
|
||||
raise CommanderPartnerError("Commander is missing a display name", details={"source": repr(source)})
|
||||
|
||||
color_identity = _normalize_colors(_get_attr(source, "color_identity") or _get_attr(source, "colorIdentity"))
|
||||
themes = _normalize_theme_tags(
|
||||
_get_attr(source, "themes")
|
||||
or _get_attr(source, "theme_tags")
|
||||
or _get_attr(source, "themeTags")
|
||||
)
|
||||
raw_tags = tuple(_ensure_sequence(_get_attr(source, "themes") or _get_attr(source, "theme_tags") or _get_attr(source, "themeTags")))
|
||||
|
||||
partner_with: Tuple[str, ...] = tuple(_ensure_sequence(_get_attr(source, "partner_with") or ()))
|
||||
oracle_text = _get_attr(source, "oracle_text") or _get_attr(source, "text")
|
||||
type_line = _get_attr(source, "type_line") or _get_attr(source, "type")
|
||||
|
||||
detection = analyze_partner_background(type_line, oracle_text, raw_tags or themes)
|
||||
if not partner_with:
|
||||
partner_with = detection.partner_with
|
||||
|
||||
is_partner = bool(_get_attr(source, "is_partner")) or detection.has_partner
|
||||
supports_backgrounds = bool(_get_attr(source, "supports_backgrounds")) or detection.choose_background
|
||||
is_background = bool(_get_attr(source, "is_background")) or detection.is_background
|
||||
is_doctor = bool(_get_attr(source, "is_doctor")) or detection.is_doctor
|
||||
is_doctors_companion = bool(_get_attr(source, "is_doctors_companion")) or detection.is_doctors_companion
|
||||
|
||||
return cls(
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
color_identity=color_identity,
|
||||
themes=themes,
|
||||
raw_tags=tuple(raw_tags),
|
||||
partner_with=partner_with,
|
||||
is_partner=is_partner,
|
||||
supports_backgrounds=supports_backgrounds,
|
||||
is_background=is_background,
|
||||
is_doctor=is_doctor,
|
||||
is_doctors_companion=is_doctors_companion,
|
||||
)
|
||||
|
||||
|
||||
def build_combined_commander(
|
||||
primary: object,
|
||||
secondary: object | None,
|
||||
mode: PartnerMode,
|
||||
) -> CombinedCommander:
|
||||
"""Merge commander metadata according to the selected partner mode."""
|
||||
|
||||
primary_data = _CommanderData.from_source(primary)
|
||||
secondary_data = _CommanderData.from_source(secondary) if secondary is not None else None
|
||||
|
||||
_validate_mode_inputs(primary_data, secondary_data, mode)
|
||||
warnings = _collect_warnings(primary_data, secondary_data)
|
||||
|
||||
color_identity = _merge_colors(primary_data, secondary_data)
|
||||
theme_tags = _merge_theme_tags(primary_data.themes, secondary_data.themes if secondary_data else ())
|
||||
raw_secondary = secondary_data.raw_tags if secondary_data else tuple()
|
||||
|
||||
secondary_name = secondary_data.display_name if secondary_data else None
|
||||
color_code = canon_color_code(color_identity)
|
||||
color_label = color_label_from_code(color_code)
|
||||
primary_colors = primary_data.color_identity
|
||||
secondary_colors = secondary_data.color_identity if secondary_data else tuple()
|
||||
|
||||
return CombinedCommander(
|
||||
primary_name=primary_data.display_name,
|
||||
secondary_name=secondary_name,
|
||||
partner_mode=mode,
|
||||
color_identity=color_identity,
|
||||
theme_tags=theme_tags,
|
||||
raw_tags_primary=primary_data.raw_tags,
|
||||
raw_tags_secondary=raw_secondary,
|
||||
warnings=warnings,
|
||||
color_code=color_code,
|
||||
color_label=color_label,
|
||||
primary_color_identity=primary_colors,
|
||||
secondary_color_identity=secondary_colors,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_mode_inputs(
|
||||
primary: _CommanderData,
|
||||
secondary: _CommanderData | None,
|
||||
mode: PartnerMode,
|
||||
) -> None:
|
||||
details = {
|
||||
"mode": mode.value,
|
||||
"primary": primary.display_name,
|
||||
"secondary": secondary.display_name if secondary else None,
|
||||
}
|
||||
|
||||
if mode is PartnerMode.NONE:
|
||||
if secondary is not None:
|
||||
raise CommanderPartnerError("Secondary commander provided but partner mode is NONE", details=details)
|
||||
return
|
||||
|
||||
if secondary is None:
|
||||
raise CommanderPartnerError("Secondary commander is required for selected partner mode", details=details)
|
||||
|
||||
_ensure_distinct(primary, secondary, details)
|
||||
|
||||
if mode is PartnerMode.PARTNER:
|
||||
if not primary.is_partner:
|
||||
raise CommanderPartnerError(f"{primary.display_name} does not have Partner", details=details)
|
||||
if not secondary.is_partner:
|
||||
raise CommanderPartnerError(f"{secondary.display_name} does not have Partner", details=details)
|
||||
if secondary.is_background:
|
||||
raise CommanderPartnerError("Selected secondary is a Background; choose partner mode BACKGROUND", details=details)
|
||||
return
|
||||
|
||||
if mode is PartnerMode.PARTNER_WITH:
|
||||
_validate_partner_with(primary, secondary, details)
|
||||
return
|
||||
|
||||
if mode is PartnerMode.BACKGROUND:
|
||||
_validate_background(primary, secondary, details)
|
||||
return
|
||||
|
||||
if mode is PartnerMode.DOCTOR_COMPANION:
|
||||
_validate_doctor_companion(primary, secondary, details)
|
||||
return
|
||||
|
||||
raise CommanderPartnerError("Unsupported partner mode", details=details)
|
||||
|
||||
|
||||
def _ensure_distinct(primary: _CommanderData, secondary: _CommanderData, details: dict[str, object]) -> None:
|
||||
if primary.display_name.casefold() == secondary.display_name.casefold():
|
||||
raise CommanderPartnerError("Primary and secondary commanders must be different", details=details)
|
||||
|
||||
|
||||
def _validate_partner_with(primary: _CommanderData, secondary: _CommanderData, details: dict[str, object]) -> None:
|
||||
if secondary.is_background:
|
||||
raise CommanderPartnerError("Background cannot be used in PARTNER_WITH mode", details=details)
|
||||
if not primary.partner_with:
|
||||
raise CommanderPartnerError(f"{primary.display_name} does not specify a Partner With target", details=details)
|
||||
if not secondary.partner_with:
|
||||
raise CommanderPartnerError(f"{secondary.display_name} does not specify a Partner With target", details=details)
|
||||
|
||||
secondary_names = {_standardize_name(name) for name in secondary.partner_with}
|
||||
primary_names = {_standardize_name(name) for name in primary.partner_with}
|
||||
if _standardize_name(secondary.display_name) not in primary_names:
|
||||
raise CommanderPartnerError(
|
||||
f"{secondary.display_name} is not a legal Partner With target for {primary.display_name}",
|
||||
details=details,
|
||||
)
|
||||
if _standardize_name(primary.display_name) not in secondary_names:
|
||||
raise CommanderPartnerError(
|
||||
f"{primary.display_name} is not a legal Partner With target for {secondary.display_name}",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def _validate_background(primary: _CommanderData, secondary: _CommanderData, details: dict[str, object]) -> None:
|
||||
if not secondary.is_background:
|
||||
raise CommanderPartnerError("Selected secondary commander is not a Background", details=details)
|
||||
if not primary.supports_backgrounds:
|
||||
raise CommanderPartnerError(f"{primary.display_name} cannot choose a Background", details=details)
|
||||
if primary.is_background:
|
||||
raise CommanderPartnerError("Background cannot be used as primary commander", details=details)
|
||||
|
||||
def _validate_doctor_companion(primary: _CommanderData, secondary: _CommanderData, details: dict[str, object]) -> None:
|
||||
primary_is_doctor = bool(primary.is_doctor)
|
||||
primary_is_companion = bool(primary.is_doctors_companion)
|
||||
secondary_is_doctor = bool(secondary.is_doctor)
|
||||
secondary_is_companion = bool(secondary.is_doctors_companion)
|
||||
|
||||
if not (primary_is_doctor or primary_is_companion):
|
||||
raise CommanderPartnerError(f"{primary.display_name} is not a Doctor or Doctor's Companion", details=details)
|
||||
if not (secondary_is_doctor or secondary_is_companion):
|
||||
raise CommanderPartnerError(f"{secondary.display_name} is not a Doctor or Doctor's Companion", details=details)
|
||||
|
||||
if primary_is_doctor and secondary_is_doctor:
|
||||
raise CommanderPartnerError("Doctor commanders must pair with a Doctor's Companion", details=details)
|
||||
if primary_is_companion and secondary_is_companion:
|
||||
raise CommanderPartnerError("Doctor's Companion must pair with a Doctor", details=details)
|
||||
|
||||
# Ensure pairing is complementary doctor <-> companion
|
||||
if primary_is_doctor and not secondary_is_companion:
|
||||
raise CommanderPartnerError(f"{secondary.display_name} is not a legal Doctor's Companion", details=details)
|
||||
if primary_is_companion and not secondary_is_doctor:
|
||||
raise CommanderPartnerError(f"{secondary.display_name} is not a legal Doctor pairing", details=details)
|
||||
|
||||
|
||||
def _collect_warnings(
|
||||
primary: _CommanderData,
|
||||
secondary: _CommanderData | None,
|
||||
) -> Tuple[str, ...]:
|
||||
warnings: list[str] = []
|
||||
if primary.is_partner and primary.supports_backgrounds:
|
||||
warnings.append(
|
||||
f"{primary.display_name} has both Partner and Background abilities; ensure the selected mode is intentional."
|
||||
)
|
||||
if secondary and secondary.is_partner and secondary.supports_backgrounds:
|
||||
warnings.append(
|
||||
f"{secondary.display_name} has both Partner and Background abilities; ensure the selected mode is intentional."
|
||||
)
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
def _merge_colors(primary: _CommanderData, secondary: _CommanderData | None) -> Tuple[str, ...]:
|
||||
colors = set(primary.color_identity)
|
||||
if secondary:
|
||||
colors.update(secondary.color_identity)
|
||||
if not colors:
|
||||
return tuple()
|
||||
return tuple(sorted(colors, key=lambda color: (_COLOR_PRIORITY.get(color, len(_COLOR_PRIORITY)), color)))
|
||||
|
||||
|
||||
def _merge_theme_tags(*sources: Iterable[str]) -> Tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
merged: list[str] = []
|
||||
for source in sources:
|
||||
for tag in source:
|
||||
clean = tag.strip()
|
||||
if not clean:
|
||||
continue
|
||||
key = clean.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append(clean)
|
||||
return tuple(merged)
|
||||
|
||||
|
||||
def _normalize_colors(colors: Sequence[str] | None) -> Tuple[str, ...]:
|
||||
if not colors:
|
||||
return tuple()
|
||||
normalized = [str(color).strip().upper() for color in colors]
|
||||
normalized = [color for color in normalized if color]
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
def _normalize_theme_tags(tags: Sequence[str] | None) -> Tuple[str, ...]:
|
||||
if not tags:
|
||||
return tuple()
|
||||
return tuple(str(tag).strip() for tag in tags if str(tag).strip())
|
||||
|
||||
|
||||
def _ensure_sequence(value: object) -> Sequence[str]:
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, (list, tuple)):
|
||||
return value
|
||||
return (value,)
|
||||
|
||||
|
||||
def _standardize_name(name: str) -> str:
|
||||
return name.strip().casefold()
|
||||
|
||||
|
||||
def _get_attr(source: object, attr: str) -> object:
|
||||
return getattr(source, attr, None)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CombinedCommander",
|
||||
"PartnerMode",
|
||||
"build_combined_commander",
|
||||
]
|
||||
287
code/deck_builder/partner_background_utils.py
Normal file
287
code/deck_builder/partner_background_utils.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"""Utilities for detecting partner and background mechanics from card data."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Iterable, Tuple, List
|
||||
|
||||
__all__ = [
|
||||
"PartnerBackgroundInfo",
|
||||
"analyze_partner_background",
|
||||
"extract_partner_with_names",
|
||||
]
|
||||
|
||||
_PARTNER_PATTERN = re.compile(r"\bPartner\b(?!\s+with)", re.IGNORECASE)
|
||||
_PARTNER_WITH_PATTERN = re.compile(r"\bPartner with ([^.;\n]+)", re.IGNORECASE)
|
||||
_CHOOSE_BACKGROUND_PATTERN = re.compile(r"\bChoose a Background\b", re.IGNORECASE)
|
||||
_BACKGROUND_KEYWORD_PATTERN = re.compile(r"\bBackground\b", re.IGNORECASE)
|
||||
_FRIENDS_FOREVER_PATTERN = re.compile(r"\bFriends forever\b", re.IGNORECASE)
|
||||
_DOCTORS_COMPANION_PATTERN = re.compile(r"Doctor's companion", re.IGNORECASE)
|
||||
_PARTNER_RESTRICTION_PATTERN = re.compile(r"\bPartner\b\s*(?:—|-|–|:)", re.IGNORECASE)
|
||||
_PARTNER_RESTRICTION_CAPTURE = re.compile(
|
||||
r"\bPartner\b\s*(?:—|-|–|:)\s*([^.;\n\r(]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PLAIN_PARTNER_THEME_TOKENS = {
|
||||
"partner",
|
||||
"partners",
|
||||
}
|
||||
_PARTNER_THEME_TOKENS = {
|
||||
"partner",
|
||||
"partners",
|
||||
"friends forever",
|
||||
"doctor's companion",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
text = value
|
||||
elif isinstance(value, float):
|
||||
if math.isnan(value):
|
||||
return ""
|
||||
text = str(value)
|
||||
else:
|
||||
text = str(value)
|
||||
stripped = text.strip()
|
||||
if stripped.casefold() == "nan":
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
def _is_background_theme_tag(tag: str) -> bool:
|
||||
text = (tag or "").strip().casefold()
|
||||
if not text:
|
||||
return False
|
||||
if "background" not in text:
|
||||
return False
|
||||
if "choose a background" in text:
|
||||
return False
|
||||
if "backgrounds matter" in text:
|
||||
return False
|
||||
normalized = text.replace("—", "-").replace("–", "-")
|
||||
if normalized in {"background", "backgrounds", "background card", "background (card type)"}:
|
||||
return True
|
||||
if normalized.startswith("background -") or normalized.startswith("background:"):
|
||||
return True
|
||||
if normalized.endswith(" background"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PartnerBackgroundInfo:
|
||||
"""Aggregated partner/background detection result."""
|
||||
|
||||
has_partner: bool
|
||||
partner_with: Tuple[str, ...]
|
||||
choose_background: bool
|
||||
is_background: bool
|
||||
is_doctor: bool
|
||||
is_doctors_companion: bool
|
||||
has_plain_partner: bool
|
||||
has_restricted_partner: bool
|
||||
restricted_partner_labels: Tuple[str, ...]
|
||||
|
||||
|
||||
def _normalize_theme_tags(tags: Iterable[str]) -> Tuple[str, ...]:
|
||||
return tuple(tag.strip().lower() for tag in tags if str(tag).strip())
|
||||
|
||||
|
||||
def extract_partner_with_names(oracle_text: str) -> Tuple[str, ...]:
|
||||
"""Extract partner-with names from oracle text.
|
||||
|
||||
Handles mixed separators ("and", "or", "&", "/") while preserving card
|
||||
names that include commas (e.g., "Pir, Imaginative Rascal"). Reminder text in
|
||||
parentheses is stripped and results are deduplicated while preserving order.
|
||||
"""
|
||||
|
||||
text = _normalize_text(oracle_text)
|
||||
if not text:
|
||||
return tuple()
|
||||
|
||||
names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for match in _PARTNER_WITH_PATTERN.finditer(text):
|
||||
raw_targets = match.group(1)
|
||||
# Remove reminder text and trailing punctuation
|
||||
until_paren = raw_targets.split("(", 1)[0]
|
||||
base_text = until_paren.strip().strip(". ")
|
||||
if not base_text:
|
||||
continue
|
||||
|
||||
segments = re.split(r"\s*(?:\band\b|\bor\b|\bplus\b|&|/|\+)\s*", base_text, flags=re.IGNORECASE)
|
||||
buffer: List[str] = []
|
||||
for token in segments:
|
||||
buffer.extend(_split_partner_token(token))
|
||||
|
||||
for item in buffer:
|
||||
cleaned = item.strip().strip("., ")
|
||||
if not cleaned:
|
||||
continue
|
||||
lowered = cleaned.casefold()
|
||||
if lowered in seen:
|
||||
continue
|
||||
seen.add(lowered)
|
||||
names.append(cleaned)
|
||||
return tuple(names)
|
||||
|
||||
|
||||
_SIMPLE_NAME_TOKEN = re.compile(r"^[A-Za-z0-9'’\-]+$")
|
||||
|
||||
|
||||
def _split_partner_token(token: str) -> List[str]:
|
||||
cleaned = (token or "").strip()
|
||||
if not cleaned:
|
||||
return []
|
||||
cleaned = cleaned.strip(",.; ")
|
||||
if not cleaned:
|
||||
return []
|
||||
|
||||
parts = [part.strip() for part in cleaned.split(",") if part.strip()]
|
||||
if len(parts) <= 1:
|
||||
return parts
|
||||
|
||||
if all(_SIMPLE_NAME_TOKEN.fullmatch(part) for part in parts):
|
||||
return parts
|
||||
|
||||
return [cleaned]
|
||||
|
||||
|
||||
def _has_plain_partner_keyword(oracle_text: str) -> bool:
|
||||
oracle_text = _normalize_text(oracle_text)
|
||||
if not oracle_text:
|
||||
return False
|
||||
for raw_line in oracle_text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
ability = line.split("(", 1)[0].strip()
|
||||
if not ability:
|
||||
continue
|
||||
lowered = ability.casefold()
|
||||
if lowered.startswith("partner with"):
|
||||
continue
|
||||
if lowered.startswith("partner"):
|
||||
suffix = ability[7:].strip()
|
||||
if suffix and suffix[0] in {"-", "—", "–", ":"}:
|
||||
continue
|
||||
if suffix:
|
||||
# Contains additional text beyond plain Partner keyword
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_partner_restriction(oracle_text: str) -> bool:
|
||||
oracle_text = _normalize_text(oracle_text)
|
||||
if not oracle_text:
|
||||
return False
|
||||
return bool(_PARTNER_RESTRICTION_PATTERN.search(oracle_text))
|
||||
|
||||
|
||||
def analyze_partner_background(
|
||||
type_line: str | None,
|
||||
oracle_text: str | None,
|
||||
theme_tags: Iterable[str] | None = None,
|
||||
) -> PartnerBackgroundInfo:
|
||||
"""Detect partner/background mechanics using text and theme tags."""
|
||||
|
||||
normalized_tags = _normalize_theme_tags(theme_tags or ())
|
||||
partner_with = extract_partner_with_names(oracle_text or "")
|
||||
type_line_text = _normalize_text(type_line)
|
||||
oracle_text_value = _normalize_text(oracle_text)
|
||||
choose_background = bool(_CHOOSE_BACKGROUND_PATTERN.search(oracle_text_value))
|
||||
theme_partner = any(tag in _PARTNER_THEME_TOKENS for tag in normalized_tags)
|
||||
theme_plain_partner = any(tag in _PLAIN_PARTNER_THEME_TOKENS for tag in normalized_tags)
|
||||
theme_choose_background = any("choose a background" in tag for tag in normalized_tags)
|
||||
theme_is_background = any(_is_background_theme_tag(tag) for tag in normalized_tags)
|
||||
friends_forever = bool(_FRIENDS_FOREVER_PATTERN.search(oracle_text_value))
|
||||
theme_friends_forever = any(tag == "friends forever" for tag in normalized_tags)
|
||||
plain_partner_keyword = _has_plain_partner_keyword(oracle_text_value)
|
||||
has_plain_partner = bool(plain_partner_keyword or theme_plain_partner)
|
||||
partner_restriction_keyword = _has_partner_restriction(oracle_text_value)
|
||||
restricted_labels = _collect_restricted_partner_labels(oracle_text_value, theme_tags)
|
||||
has_restricted_partner = bool(
|
||||
partner_with
|
||||
or partner_restriction_keyword
|
||||
or friends_forever
|
||||
or theme_friends_forever
|
||||
or restricted_labels
|
||||
)
|
||||
|
||||
creature_segment = ""
|
||||
if type_line_text:
|
||||
if "—" in type_line_text:
|
||||
creature_segment = type_line_text.split("—", 1)[1]
|
||||
elif "-" in type_line_text:
|
||||
creature_segment = type_line_text.split("-", 1)[1]
|
||||
else:
|
||||
creature_segment = type_line_text
|
||||
type_tokens = {part.strip().lower() for part in creature_segment.split() if part.strip()}
|
||||
has_time_lord_doctor = {"time", "lord", "doctor"}.issubset(type_tokens)
|
||||
is_doctor = bool(has_time_lord_doctor)
|
||||
is_doctors_companion = bool(_DOCTORS_COMPANION_PATTERN.search(oracle_text_value))
|
||||
if not is_doctors_companion:
|
||||
is_doctors_companion = any("doctor" in tag and "companion" in tag for tag in normalized_tags)
|
||||
|
||||
has_partner = bool(has_plain_partner or has_restricted_partner or theme_partner)
|
||||
choose_background = choose_background or theme_choose_background
|
||||
is_background = bool(_BACKGROUND_KEYWORD_PATTERN.search(type_line_text)) or theme_is_background
|
||||
|
||||
return PartnerBackgroundInfo(
|
||||
has_partner=has_partner,
|
||||
partner_with=partner_with,
|
||||
choose_background=choose_background,
|
||||
is_background=is_background,
|
||||
is_doctor=is_doctor,
|
||||
is_doctors_companion=is_doctors_companion,
|
||||
has_plain_partner=has_plain_partner,
|
||||
has_restricted_partner=has_restricted_partner,
|
||||
restricted_partner_labels=restricted_labels,
|
||||
)
|
||||
|
||||
|
||||
def _collect_restricted_partner_labels(
|
||||
oracle_text: str,
|
||||
theme_tags: Iterable[str] | None,
|
||||
) -> Tuple[str, ...]:
|
||||
labels: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _maybe_add(raw: str | None) -> None:
|
||||
if not raw:
|
||||
return
|
||||
cleaned = raw.strip().strip("-—–: ")
|
||||
if not cleaned:
|
||||
return
|
||||
key = cleaned.casefold()
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
labels.append(cleaned)
|
||||
|
||||
oracle_text = _normalize_text(oracle_text)
|
||||
for match in _PARTNER_RESTRICTION_CAPTURE.finditer(oracle_text):
|
||||
value = match.group(1)
|
||||
value = value.split("(", 1)[0]
|
||||
value = value.strip().rstrip(".,;:—-– ")
|
||||
_maybe_add(value)
|
||||
|
||||
if theme_tags:
|
||||
for tag in theme_tags:
|
||||
text = _normalize_text(tag).strip()
|
||||
if not text:
|
||||
continue
|
||||
lowered = text.casefold()
|
||||
if not lowered.startswith("partner"):
|
||||
continue
|
||||
parts = re.split(r"[—\-–:]", text, maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
_maybe_add(parts[1])
|
||||
|
||||
return tuple(labels)
|
||||
426
code/deck_builder/partner_selection.py
Normal file
426
code/deck_builder/partner_selection.py
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
"""Helpers for applying partner/background inputs to a deck build."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from exceptions import CommanderPartnerError
|
||||
from deck_builder.background_loader import load_background_cards
|
||||
from deck_builder.combined_commander import (
|
||||
CombinedCommander,
|
||||
PartnerMode,
|
||||
build_combined_commander,
|
||||
)
|
||||
from logging_util import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
try: # Optional pandas import for type checking without heavy dependency at runtime.
|
||||
import pandas as _pd # type: ignore
|
||||
except Exception: # pragma: no cover - tests provide DataFrame-like objects.
|
||||
_pd = None # type: ignore
|
||||
|
||||
__all__ = ["apply_partner_inputs", "normalize_lookup_name"]
|
||||
|
||||
|
||||
def normalize_lookup_name(value: str | None) -> str:
|
||||
"""Normalize a commander/background name for case-insensitive lookups."""
|
||||
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def apply_partner_inputs(
|
||||
builder: Any,
|
||||
*,
|
||||
primary_name: str,
|
||||
secondary_name: str | None = None,
|
||||
background_name: str | None = None,
|
||||
feature_enabled: bool = False,
|
||||
background_catalog: Any | None = None,
|
||||
selection_source: str | None = None,
|
||||
) -> CombinedCommander | None:
|
||||
"""Apply partner/background inputs to a builder if the feature is enabled.
|
||||
|
||||
Args:
|
||||
builder: Deck builder instance exposing ``load_commander_data``.
|
||||
primary_name: The selected primary commander name.
|
||||
secondary_name: Optional partner/partner-with commander name.
|
||||
background_name: Optional background name.
|
||||
feature_enabled: Whether partner mechanics are enabled for this run.
|
||||
background_catalog: Optional override for background catalog (testing).
|
||||
selection_source: Optional tag describing how the selection was made (e.g., "suggestion").
|
||||
|
||||
Returns:
|
||||
CombinedCommander when a partner/background pairing is produced; ``None``
|
||||
when the feature is disabled or no secondary/background inputs are given.
|
||||
|
||||
Raises:
|
||||
CommanderPartnerError: If inputs are invalid or commanders cannot be
|
||||
combined under rules constraints.
|
||||
"""
|
||||
|
||||
if not feature_enabled:
|
||||
return None
|
||||
|
||||
secondary_name = _coerce_name(secondary_name)
|
||||
background_name = _coerce_name(background_name)
|
||||
|
||||
if not primary_name:
|
||||
return None
|
||||
|
||||
clean_selection_source = (selection_source or "").strip().lower() or None
|
||||
|
||||
if secondary_name and background_name:
|
||||
raise CommanderPartnerError(
|
||||
"Provide either 'secondary_commander' or 'background', not both.",
|
||||
details={
|
||||
"primary": primary_name,
|
||||
"secondary_commander": secondary_name,
|
||||
"background": background_name,
|
||||
},
|
||||
)
|
||||
|
||||
if not secondary_name and not background_name:
|
||||
return None
|
||||
|
||||
commander_df = builder.load_commander_data()
|
||||
primary_row = _find_commander_row(commander_df, primary_name)
|
||||
if primary_row is None:
|
||||
raise CommanderPartnerError(
|
||||
f"Primary commander not found: {primary_name}",
|
||||
details={"commander": primary_name},
|
||||
)
|
||||
|
||||
primary_source = _row_to_commander_source(primary_row)
|
||||
|
||||
if background_name:
|
||||
catalog = background_catalog or load_background_cards()
|
||||
background_card = _lookup_background_card(catalog, background_name)
|
||||
if background_card is None:
|
||||
raise CommanderPartnerError(
|
||||
f"Background not found: {background_name}",
|
||||
details={"background": background_name},
|
||||
)
|
||||
combined = build_combined_commander(primary_source, background_card, PartnerMode.BACKGROUND)
|
||||
_log_partner_selection(
|
||||
combined,
|
||||
primary_source=primary_source,
|
||||
secondary_source=None,
|
||||
background_source=background_card,
|
||||
selection_source=clean_selection_source,
|
||||
)
|
||||
return combined
|
||||
|
||||
# Partner/Partner With flow
|
||||
secondary_row = _find_commander_row(commander_df, secondary_name)
|
||||
if secondary_row is None:
|
||||
raise CommanderPartnerError(
|
||||
f"Secondary commander not found: {secondary_name}",
|
||||
details={"secondary_commander": secondary_name},
|
||||
)
|
||||
|
||||
secondary_source = _row_to_commander_source(secondary_row)
|
||||
errors: list[CommanderPartnerError] = []
|
||||
combined: CombinedCommander | None = None
|
||||
for mode in (PartnerMode.PARTNER_WITH, PartnerMode.DOCTOR_COMPANION, PartnerMode.PARTNER):
|
||||
try:
|
||||
combined = build_combined_commander(primary_source, secondary_source, mode)
|
||||
break
|
||||
except CommanderPartnerError as exc:
|
||||
errors.append(exc)
|
||||
|
||||
if combined is not None:
|
||||
_log_partner_selection(
|
||||
combined,
|
||||
primary_source=primary_source,
|
||||
secondary_source=secondary_source,
|
||||
background_source=None,
|
||||
selection_source=clean_selection_source,
|
||||
)
|
||||
return combined
|
||||
|
||||
if errors:
|
||||
raise errors[-1]
|
||||
raise CommanderPartnerError("Unable to combine commanders with provided inputs.")
|
||||
|
||||
|
||||
def _coerce_name(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _log_partner_selection(
|
||||
combined: CombinedCommander,
|
||||
*,
|
||||
primary_source: Any,
|
||||
secondary_source: Any | None,
|
||||
background_source: Any | None,
|
||||
selection_source: str | None = None,
|
||||
) -> None:
|
||||
mode_value = combined.partner_mode.value if isinstance(combined.partner_mode, PartnerMode) else str(combined.partner_mode)
|
||||
secondary_role = _secondary_role_for_mode(combined.partner_mode)
|
||||
|
||||
combined_colors = list(combined.color_identity or ())
|
||||
primary_colors = list(combined.primary_color_identity or _safe_colors_from_source(primary_source))
|
||||
if secondary_source is not None:
|
||||
secondary_colors = list(combined.secondary_color_identity or _safe_colors_from_source(secondary_source))
|
||||
else:
|
||||
secondary_colors = list(_safe_colors_from_source(background_source))
|
||||
|
||||
color_delta = {
|
||||
"added": [color for color in combined_colors if color not in primary_colors],
|
||||
"removed": [color for color in primary_colors if color not in combined_colors],
|
||||
"primary": primary_colors,
|
||||
"secondary": secondary_colors,
|
||||
}
|
||||
|
||||
primary_description = _describe_source(primary_source)
|
||||
secondary_description = _describe_source(secondary_source)
|
||||
background_description = _describe_source(background_source)
|
||||
|
||||
commanders = {
|
||||
"primary": combined.primary_name,
|
||||
"secondary": combined.secondary_name,
|
||||
"background": (background_description or {}).get("display_name"),
|
||||
}
|
||||
sources = {
|
||||
"primary": primary_description,
|
||||
"secondary": secondary_description,
|
||||
"background": background_description,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"mode": mode_value,
|
||||
"secondary_role": secondary_role,
|
||||
"primary_name": commanders["primary"],
|
||||
"secondary_name": commanders["secondary"],
|
||||
"background_name": commanders["background"],
|
||||
"commanders": commanders,
|
||||
"color_identity": combined_colors,
|
||||
"colors_after": combined_colors,
|
||||
"colors_before": primary_colors,
|
||||
"color_code": combined.color_code,
|
||||
"color_label": combined.color_label,
|
||||
"color_delta": color_delta,
|
||||
"primary_source": sources["primary"],
|
||||
"secondary_source": sources["secondary"],
|
||||
"background_source": sources["background"],
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
if selection_source:
|
||||
payload["selection_source"] = selection_source
|
||||
|
||||
logger.info(
|
||||
"partner_mode_selected",
|
||||
extra={
|
||||
"event": "partner_mode_selected",
|
||||
"payload": payload,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _secondary_role_for_mode(mode: PartnerMode) -> str:
|
||||
if mode is PartnerMode.BACKGROUND:
|
||||
return "background"
|
||||
if mode is PartnerMode.DOCTOR_COMPANION:
|
||||
return "companion"
|
||||
if mode is PartnerMode.PARTNER_WITH:
|
||||
return "partner_with"
|
||||
if mode is PartnerMode.PARTNER:
|
||||
return "partner"
|
||||
return "secondary"
|
||||
|
||||
|
||||
def _safe_colors_from_source(source: Any | None) -> list[str]:
|
||||
if source is None:
|
||||
return []
|
||||
value = getattr(source, "color_identity", None) or getattr(source, "colors", None)
|
||||
return list(_normalize_color_identity(value))
|
||||
|
||||
|
||||
def _describe_source(source: Any | None) -> dict[str, object] | None:
|
||||
if source is None:
|
||||
return None
|
||||
name = getattr(source, "name", None) or getattr(source, "display_name", None)
|
||||
display_name = getattr(source, "display_name", None) or name
|
||||
partner_with = getattr(source, "partner_with", None)
|
||||
if partner_with is None:
|
||||
partner_with = getattr(source, "partnerWith", None)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"color_identity": _safe_colors_from_source(source),
|
||||
"themes": list(getattr(source, "themes", ()) or getattr(source, "theme_tags", ()) or []),
|
||||
"partner_with": list(partner_with or ()),
|
||||
}
|
||||
|
||||
|
||||
def _find_commander_row(df: Any, name: str | None):
|
||||
if name is None:
|
||||
return None
|
||||
target = normalize_lookup_name(name)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
if _pd is not None and isinstance(df, _pd.DataFrame): # type: ignore
|
||||
columns = [col for col in ("name", "faceName") if col in df.columns]
|
||||
for col in columns:
|
||||
series = df[col].astype(str).str.casefold()
|
||||
matches = df[series == target]
|
||||
if not matches.empty:
|
||||
return matches.iloc[0]
|
||||
return None
|
||||
|
||||
# Fallback for DataFrame-like sequences
|
||||
for row in getattr(df, "itertuples", lambda index=False: [])(): # pragma: no cover - defensive
|
||||
for attr in ("name", "faceName"):
|
||||
value = getattr(row, attr, None)
|
||||
if normalize_lookup_name(value) == target:
|
||||
return getattr(df, "loc", lambda *_: row)(row.Index) if hasattr(row, "Index") else row
|
||||
return None
|
||||
|
||||
|
||||
def _row_to_commander_source(row: Any) -> SimpleNamespace:
|
||||
themes = _normalize_string_sequence(row.get("themeTags"))
|
||||
partner_with = _normalize_string_sequence(
|
||||
row.get("partnerWith")
|
||||
or row.get("partner_with")
|
||||
or row.get("partnerNames")
|
||||
or row.get("partner_names")
|
||||
)
|
||||
|
||||
return SimpleNamespace(
|
||||
name=_safe_str(row.get("name")),
|
||||
display_name=_safe_str(row.get("faceName")) or _safe_str(row.get("name")),
|
||||
color_identity=_normalize_color_identity(row.get("colorIdentity")),
|
||||
colors=_normalize_color_identity(row.get("colors")),
|
||||
themes=themes,
|
||||
theme_tags=themes,
|
||||
raw_tags=themes,
|
||||
partner_with=partner_with,
|
||||
oracle_text=_safe_str(row.get("text") or row.get("oracleText")),
|
||||
type_line=_safe_str(row.get("type") or row.get("type_line")),
|
||||
supports_backgrounds=_normalize_bool(row.get("supportsBackgrounds") or row.get("supports_backgrounds")),
|
||||
is_partner=_normalize_bool(row.get("isPartner") or row.get("is_partner")),
|
||||
is_background=_normalize_bool(row.get("isBackground") or row.get("is_background")),
|
||||
is_doctor=_normalize_bool(row.get("isDoctor") or row.get("is_doctor")),
|
||||
is_doctors_companion=_normalize_bool(row.get("isDoctorsCompanion") or row.get("is_doctors_companion")),
|
||||
)
|
||||
|
||||
|
||||
def _lookup_background_card(catalog: Any, name: str) -> Any | None:
|
||||
lowered = normalize_lookup_name(name)
|
||||
|
||||
getter = getattr(catalog, "get", None)
|
||||
if callable(getter):
|
||||
result = getter(name)
|
||||
if result is None:
|
||||
result = getter(lowered)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
entries = getattr(catalog, "entries", None)
|
||||
if entries is not None:
|
||||
for entry in entries:
|
||||
display = normalize_lookup_name(getattr(entry, "display_name", None))
|
||||
if display == lowered:
|
||||
return entry
|
||||
raw = normalize_lookup_name(getattr(entry, "name", None))
|
||||
if raw == lowered:
|
||||
return entry
|
||||
slug = normalize_lookup_name(getattr(entry, "slug", None))
|
||||
if slug == lowered:
|
||||
return entry
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_color_identity(value: Any) -> tuple[str, ...]:
|
||||
tokens = _normalize_string_sequence(value)
|
||||
result: list[str] = []
|
||||
for token in tokens:
|
||||
if len(token) > 1 and "," not in token and " " not in token:
|
||||
if all(ch in "WUBRGC" for ch in token):
|
||||
result.extend(ch for ch in token)
|
||||
else:
|
||||
result.append(token)
|
||||
else:
|
||||
result.append(token)
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for item in result:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
ordered.append(item)
|
||||
return tuple(ordered)
|
||||
|
||||
|
||||
def _normalize_string_sequence(value: Any) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return tuple()
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
items = list(value)
|
||||
else:
|
||||
text = _safe_str(value)
|
||||
if not text:
|
||||
return tuple()
|
||||
try:
|
||||
parsed = ast.literal_eval(text)
|
||||
except Exception: # pragma: no cover - non literal values handled below
|
||||
parsed = None
|
||||
if isinstance(parsed, (list, tuple, set)):
|
||||
items = list(parsed)
|
||||
elif ";" in text:
|
||||
items = [part.strip() for part in text.split(";")]
|
||||
elif "," in text:
|
||||
items = [part.strip() for part in text.split(",")]
|
||||
else:
|
||||
items = [text]
|
||||
collected: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
token = _safe_str(item)
|
||||
if not token:
|
||||
continue
|
||||
key = token.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
collected.append(token)
|
||||
return tuple(collected)
|
||||
|
||||
|
||||
def _normalize_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value in (0, 1):
|
||||
return bool(value)
|
||||
text = _safe_str(value).casefold()
|
||||
if not text:
|
||||
return False
|
||||
if text in {"1", "true", "t", "yes", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "f", "no", "off"}:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _safe_str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value != value: # NaN check
|
||||
return ""
|
||||
text = str(value)
|
||||
if "\\r\\n" in text or "\\n" in text or "\\r" in text:
|
||||
text = (
|
||||
text.replace("\\r\\n", "\n")
|
||||
.replace("\\r", "\n")
|
||||
.replace("\\n", "\n")
|
||||
)
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
return text.strip()
|
||||
|
|
@ -7,7 +7,8 @@ import datetime as _dt
|
|||
import re as _re
|
||||
import logging_util
|
||||
|
||||
from code.deck_builder.summary_telemetry import record_land_summary, record_theme_summary
|
||||
from code.deck_builder.summary_telemetry import record_land_summary, record_theme_summary, record_partner_summary
|
||||
from code.deck_builder.color_identity_utils import normalize_colors, canon_color_code, color_label_from_code
|
||||
from code.deck_builder.shared_copy import build_land_headline, dfc_card_note
|
||||
|
||||
logger = logging_util.logging.getLogger(__name__)
|
||||
|
|
@ -27,6 +28,144 @@ class ReportingMixin:
|
|||
"""Public method for orchestration: delegates to print_type_summary and print_card_library."""
|
||||
self.print_type_summary()
|
||||
self.print_card_library(table=True)
|
||||
|
||||
def get_commander_export_metadata(self) -> Dict[str, Any]:
|
||||
"""Return metadata describing the active commander configuration for export surfaces."""
|
||||
|
||||
def _clean(value: object) -> str:
|
||||
try:
|
||||
text = str(value).strip()
|
||||
except Exception:
|
||||
text = ""
|
||||
return text
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"primary_commander": None,
|
||||
"secondary_commander": None,
|
||||
"commander_names": [],
|
||||
"partner_mode": None,
|
||||
"color_identity": [],
|
||||
}
|
||||
|
||||
combined = getattr(self, 'combined_commander', None)
|
||||
|
||||
commander_names: list[str] = []
|
||||
primary_name = None
|
||||
secondary_name = None
|
||||
|
||||
if combined is not None:
|
||||
primary_name = _clean(getattr(combined, 'primary_name', '')) or None
|
||||
secondary_name = _clean(getattr(combined, 'secondary_name', '')) or None
|
||||
partner_mode_obj = getattr(combined, 'partner_mode', None)
|
||||
partner_mode_val = getattr(partner_mode_obj, 'value', None)
|
||||
if isinstance(partner_mode_val, str) and partner_mode_val.strip():
|
||||
metadata["partner_mode"] = partner_mode_val.strip()
|
||||
elif isinstance(partner_mode_obj, str) and partner_mode_obj.strip():
|
||||
metadata["partner_mode"] = partner_mode_obj.strip()
|
||||
if primary_name:
|
||||
commander_names.append(primary_name)
|
||||
if secondary_name and all(secondary_name.casefold() != n.casefold() for n in commander_names):
|
||||
commander_names.append(secondary_name)
|
||||
combined_identity_raw = list(getattr(combined, 'color_identity', []) or [])
|
||||
combined_colors = normalize_colors(combined_identity_raw)
|
||||
primary_colors = normalize_colors(getattr(combined, 'primary_color_identity', ()))
|
||||
secondary_colors = normalize_colors(getattr(combined, 'secondary_color_identity', ()))
|
||||
color_code = getattr(combined, 'color_code', '') or canon_color_code(combined_identity_raw)
|
||||
color_label = getattr(combined, 'color_label', '') or color_label_from_code(color_code)
|
||||
|
||||
mode_lower = (metadata["partner_mode"] or "").lower() if metadata.get("partner_mode") else ""
|
||||
if mode_lower == "background":
|
||||
secondary_role = "background"
|
||||
elif mode_lower == "doctor_companion":
|
||||
secondary_role = "companion"
|
||||
elif mode_lower == "partner_with":
|
||||
secondary_role = "partner_with"
|
||||
elif mode_lower == "partner":
|
||||
secondary_role = "partner"
|
||||
else:
|
||||
secondary_role = "secondary"
|
||||
|
||||
secondary_role_label_map = {
|
||||
"background": "Background",
|
||||
"companion": "Doctor pairing",
|
||||
"partner_with": "Partner With",
|
||||
"partner": "Partner commander",
|
||||
}
|
||||
secondary_role_label = secondary_role_label_map.get(secondary_role, "Partner commander")
|
||||
|
||||
color_sources: list[Dict[str, Any]] = []
|
||||
for color in combined_colors:
|
||||
providers: list[Dict[str, Any]] = []
|
||||
if primary_name and color in primary_colors:
|
||||
providers.append({"name": primary_name, "role": "primary"})
|
||||
if secondary_name and color in secondary_colors:
|
||||
providers.append({"name": secondary_name, "role": secondary_role})
|
||||
if not providers and primary_name:
|
||||
providers.append({"name": primary_name, "role": "primary"})
|
||||
color_sources.append({"color": color, "providers": providers})
|
||||
|
||||
added_colors = [c for c in combined_colors if c not in primary_colors]
|
||||
removed_colors = [c for c in primary_colors if c not in combined_colors]
|
||||
|
||||
combined_payload = {
|
||||
"primary_name": primary_name,
|
||||
"secondary_name": secondary_name,
|
||||
"partner_mode": metadata["partner_mode"],
|
||||
"color_identity": combined_identity_raw,
|
||||
"theme_tags": list(getattr(combined, 'theme_tags', []) or []),
|
||||
"raw_tags_primary": list(getattr(combined, 'raw_tags_primary', []) or []),
|
||||
"raw_tags_secondary": list(getattr(combined, 'raw_tags_secondary', []) or []),
|
||||
"warnings": list(getattr(combined, 'warnings', []) or []),
|
||||
"color_code": color_code,
|
||||
"color_label": color_label,
|
||||
"primary_color_identity": primary_colors,
|
||||
"secondary_color_identity": secondary_colors,
|
||||
"secondary_role": secondary_role,
|
||||
"secondary_role_label": secondary_role_label,
|
||||
"color_sources": color_sources,
|
||||
"color_delta": {
|
||||
"added": added_colors,
|
||||
"removed": removed_colors,
|
||||
"primary": primary_colors,
|
||||
"secondary": secondary_colors,
|
||||
},
|
||||
}
|
||||
metadata["combined_commander"] = combined_payload
|
||||
else:
|
||||
primary_attr = _clean(getattr(self, 'commander_name', '') or getattr(self, 'commander', ''))
|
||||
if primary_attr:
|
||||
primary_name = primary_attr
|
||||
commander_names.append(primary_attr)
|
||||
secondary_attr = _clean(getattr(self, 'secondary_commander', ''))
|
||||
if secondary_attr and all(secondary_attr.casefold() != n.casefold() for n in commander_names):
|
||||
secondary_name = secondary_attr
|
||||
commander_names.append(secondary_attr)
|
||||
partner_mode_attr = getattr(self, 'partner_mode', None)
|
||||
partner_mode_val = getattr(partner_mode_attr, 'value', None)
|
||||
if isinstance(partner_mode_val, str) and partner_mode_val.strip():
|
||||
metadata["partner_mode"] = partner_mode_val.strip()
|
||||
elif isinstance(partner_mode_attr, str) and partner_mode_attr.strip():
|
||||
metadata["partner_mode"] = partner_mode_attr.strip()
|
||||
|
||||
metadata["primary_commander"] = primary_name
|
||||
metadata["secondary_commander"] = secondary_name
|
||||
metadata["commander_names"] = commander_names
|
||||
|
||||
if metadata["partner_mode"]:
|
||||
metadata["partner_mode"] = metadata["partner_mode"].lower()
|
||||
|
||||
# Prefer combined color identity when available
|
||||
color_source = None
|
||||
if combined is not None:
|
||||
color_source = getattr(combined, 'color_identity', None)
|
||||
if not color_source:
|
||||
color_source = getattr(self, 'combined_color_identity', None)
|
||||
if not color_source:
|
||||
color_source = getattr(self, 'color_identity', None)
|
||||
if color_source:
|
||||
metadata["color_identity"] = [str(c).strip().upper() for c in color_source if str(c).strip()]
|
||||
|
||||
return metadata
|
||||
"""Phase 6: Reporting, summaries, and export helpers."""
|
||||
|
||||
def enforce_and_reexport(self, base_stem: str | None = None, mode: str = "prompt") -> dict:
|
||||
|
|
@ -623,6 +762,27 @@ class ReportingMixin:
|
|||
'colors': list(getattr(self, 'color_identity', []) or []),
|
||||
'include_exclude_summary': include_exclude_summary,
|
||||
}
|
||||
|
||||
try:
|
||||
commander_meta = self.get_commander_export_metadata()
|
||||
except Exception:
|
||||
commander_meta = {}
|
||||
commander_names = commander_meta.get('commander_names') or []
|
||||
if commander_names:
|
||||
summary_payload['commander'] = {
|
||||
'names': commander_names,
|
||||
'primary': commander_meta.get('primary_commander'),
|
||||
'secondary': commander_meta.get('secondary_commander'),
|
||||
'partner_mode': commander_meta.get('partner_mode'),
|
||||
'color_identity': commander_meta.get('color_identity') or list(getattr(self, 'color_identity', []) or []),
|
||||
}
|
||||
combined_payload = commander_meta.get('combined_commander')
|
||||
if combined_payload:
|
||||
summary_payload['commander']['combined'] = combined_payload
|
||||
try:
|
||||
record_partner_summary(summary_payload['commander'])
|
||||
except Exception: # pragma: no cover - diagnostics only
|
||||
logger.debug("Failed to record partner telemetry", exc_info=True)
|
||||
try:
|
||||
record_land_summary(land_summary)
|
||||
except Exception: # pragma: no cover - diagnostics only
|
||||
|
|
@ -721,6 +881,17 @@ class ReportingMixin:
|
|||
"Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","Text","DFCNote","Owned"
|
||||
]
|
||||
|
||||
header_suffix: List[str] = []
|
||||
try:
|
||||
commander_meta = self.get_commander_export_metadata()
|
||||
except Exception:
|
||||
commander_meta = {}
|
||||
commander_names = commander_meta.get('commander_names') or []
|
||||
if commander_names:
|
||||
header_suffix.append(f"Commanders: {', '.join(commander_names)}")
|
||||
header_row = headers + header_suffix
|
||||
suffix_padding = [''] * len(header_suffix)
|
||||
|
||||
# Precedence list for sorting
|
||||
precedence_order = [
|
||||
'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land'
|
||||
|
|
@ -853,9 +1024,12 @@ class ReportingMixin:
|
|||
|
||||
with open(fname, 'w', newline='', encoding='utf-8') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(headers)
|
||||
w.writerow(header_row)
|
||||
for _, data_row in rows:
|
||||
w.writerow(data_row)
|
||||
if suffix_padding:
|
||||
w.writerow(data_row + suffix_padding)
|
||||
else:
|
||||
w.writerow(data_row)
|
||||
|
||||
self.output_func(f"Deck exported to {fname}")
|
||||
# Auto-generate matching plaintext list (best-effort; ignore failures)
|
||||
|
|
@ -979,7 +1153,24 @@ class ReportingMixin:
|
|||
sortable.append(((prec, name.lower()), name, info.get('Count',1), dfc_note))
|
||||
sortable.sort(key=lambda x: x[0])
|
||||
|
||||
try:
|
||||
commander_meta = self.get_commander_export_metadata()
|
||||
except Exception:
|
||||
commander_meta = {}
|
||||
header_lines: List[str] = []
|
||||
commander_names = commander_meta.get('commander_names') or []
|
||||
if commander_names:
|
||||
header_lines.append(f"# Commanders: {', '.join(commander_names)}")
|
||||
partner_mode = commander_meta.get('partner_mode')
|
||||
if partner_mode and partner_mode not in (None, '', 'none'):
|
||||
header_lines.append(f"# Partner Mode: {partner_mode}")
|
||||
color_identity = commander_meta.get('color_identity') or []
|
||||
if color_identity:
|
||||
header_lines.append(f"# Colors: {', '.join(color_identity)}")
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
if header_lines:
|
||||
f.write("\n".join(header_lines) + "\n\n")
|
||||
for _, name, count, dfc_note in sortable:
|
||||
line = f"{count} {name}"
|
||||
if dfc_note:
|
||||
|
|
@ -1001,6 +1192,9 @@ class ReportingMixin:
|
|||
- add_lands, add_creatures, add_non_creature_spells (defaults True)
|
||||
- fetch_count (if determined during run)
|
||||
- ideal_counts (the actual ideal composition values used)
|
||||
- secondary_commander (when partner mechanics apply)
|
||||
- background (when Choose a Background is used)
|
||||
- enable_partner_mechanics flag (bool, default False)
|
||||
"""
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
|
|
@ -1019,6 +1213,26 @@ class ReportingMixin:
|
|||
return candidate
|
||||
i += 1
|
||||
|
||||
def _clean_text(value: object | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.lower() == "none":
|
||||
return None
|
||||
return text
|
||||
try:
|
||||
text = str(value).strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not text:
|
||||
return None
|
||||
if text.lower() == "none":
|
||||
return None
|
||||
return text
|
||||
|
||||
if filename is None:
|
||||
# Prefer a custom export base when present; else commander/themes
|
||||
try:
|
||||
|
|
@ -1059,6 +1273,57 @@ class ReportingMixin:
|
|||
]
|
||||
theme_catalog_version = getattr(self, 'theme_catalog_version', None)
|
||||
|
||||
partner_enabled_flag = bool(getattr(self, 'partner_feature_enabled', False))
|
||||
requested_secondary = _clean_text(getattr(self, 'requested_secondary_commander', None))
|
||||
requested_background = _clean_text(getattr(self, 'requested_background', None))
|
||||
stored_secondary = _clean_text(getattr(self, 'secondary_commander', None))
|
||||
stored_background = _clean_text(getattr(self, 'background', None))
|
||||
|
||||
metadata: Dict[str, Any] = {}
|
||||
try:
|
||||
metadata_candidate = self.get_commander_export_metadata()
|
||||
except Exception:
|
||||
metadata_candidate = {}
|
||||
if isinstance(metadata_candidate, dict):
|
||||
metadata = metadata_candidate
|
||||
|
||||
partner_mode = str(metadata.get("partner_mode") or "").strip().lower() if metadata else ""
|
||||
metadata_secondary = _clean_text(metadata.get("secondary_commander")) if metadata else None
|
||||
combined_secondary = None
|
||||
combined_info = metadata.get("combined_commander") if metadata else None
|
||||
if isinstance(combined_info, dict):
|
||||
combined_secondary = _clean_text(combined_info.get("secondary_name"))
|
||||
|
||||
if partner_mode and partner_mode not in {"none", ""}:
|
||||
partner_enabled_flag = True if not partner_enabled_flag else partner_enabled_flag
|
||||
|
||||
secondary_for_export = None
|
||||
background_for_export = None
|
||||
if partner_mode == "background":
|
||||
background_for_export = (
|
||||
combined_secondary
|
||||
or requested_background
|
||||
or metadata_secondary
|
||||
or stored_background
|
||||
or stored_secondary
|
||||
)
|
||||
else:
|
||||
secondary_for_export = (
|
||||
combined_secondary
|
||||
or requested_secondary
|
||||
or metadata_secondary
|
||||
or stored_secondary
|
||||
)
|
||||
background_for_export = requested_background or stored_background
|
||||
|
||||
secondary_for_export = _clean_text(secondary_for_export)
|
||||
background_for_export = _clean_text(background_for_export)
|
||||
|
||||
if partner_mode == "background":
|
||||
secondary_for_export = None
|
||||
|
||||
enable_partner_flag = bool(partner_enabled_flag)
|
||||
|
||||
payload = {
|
||||
"commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '',
|
||||
"primary_tag": getattr(self, 'primary_tag', None),
|
||||
|
|
@ -1086,6 +1351,9 @@ class ReportingMixin:
|
|||
# CamelCase aliases for downstream consumers (web diagnostics, external tooling)
|
||||
"userThemes": user_themes,
|
||||
"themeCatalogVersion": theme_catalog_version,
|
||||
"secondary_commander": secondary_for_export,
|
||||
"background": background_for_export,
|
||||
"enable_partner_mechanics": enable_partner_flag,
|
||||
# chosen fetch land count (others intentionally omitted for variance)
|
||||
"fetch_count": chosen_fetch,
|
||||
# actual ideal counts used for this run
|
||||
|
|
|
|||
|
|
@ -1550,6 +1550,28 @@ def build_random_full_deck(
|
|||
custom_base = None
|
||||
if isinstance(custom_base, str) and custom_base.strip():
|
||||
meta_payload["name"] = custom_base.strip()
|
||||
try:
|
||||
commander_meta = builder.get_commander_export_metadata() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
commander_meta = {}
|
||||
names = commander_meta.get("commander_names") or []
|
||||
if names:
|
||||
meta_payload["commander_names"] = names
|
||||
combined_payload = commander_meta.get("combined_commander")
|
||||
if combined_payload:
|
||||
meta_payload["combined_commander"] = combined_payload
|
||||
partner_mode = commander_meta.get("partner_mode")
|
||||
if partner_mode:
|
||||
meta_payload["partner_mode"] = partner_mode
|
||||
color_identity = commander_meta.get("color_identity")
|
||||
if color_identity:
|
||||
meta_payload["color_identity"] = color_identity
|
||||
primary_commander = commander_meta.get("primary_commander")
|
||||
if primary_commander:
|
||||
meta_payload["commander"] = primary_commander
|
||||
secondary_commander = commander_meta.get("secondary_commander")
|
||||
if secondary_commander:
|
||||
meta_payload["secondary_commander"] = secondary_commander
|
||||
return meta_payload
|
||||
|
||||
# Attempt to reuse existing export performed inside builder (headless run already exported)
|
||||
|
|
|
|||
662
code/deck_builder/suggestions.py
Normal file
662
code/deck_builder/suggestions.py
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
"""Partner suggestion scoring helpers.
|
||||
|
||||
This module provides a scoring helper that ranks potential partner/background
|
||||
pairings for a selected primary commander. It consumes the normalized metadata
|
||||
emitted by ``build_partner_suggestions.py`` (themes, role tags, partner flags,
|
||||
and pairing telemetry) and blends several weighted components:
|
||||
|
||||
* Shared theme overlap (normalized Jaccard/role-aware) – baseline synergy.
|
||||
* Theme adjacency (deck export co-occurrence + curated overrides).
|
||||
* Color compatibility (prefers compact color changes).
|
||||
* Mechanic affinity (Partner With, Doctor/Companion, Background matches).
|
||||
* Penalties (illegal configurations, missing tags, restricted conflicts).
|
||||
|
||||
Weights are mode-specific so future tuning can adjust emphasis without
|
||||
rewriting the algorithm. The public ``score_partner_candidate`` helper returns
|
||||
both the aggregate score and a component breakdown for diagnostics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Dict, Iterable, Mapping, MutableMapping, Sequence
|
||||
|
||||
from .combined_commander import PartnerMode
|
||||
|
||||
__all__ = [
|
||||
"PartnerSuggestionContext",
|
||||
"ScoreWeights",
|
||||
"ScoreResult",
|
||||
"MODE_WEIGHTS",
|
||||
"score_partner_candidate",
|
||||
"is_noise_theme",
|
||||
]
|
||||
|
||||
|
||||
def _clean_str(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _normalize_token(value: str | None) -> str:
|
||||
return _clean_str(value).casefold()
|
||||
|
||||
|
||||
def _commander_name(payload: Mapping[str, object]) -> str:
|
||||
name = _clean_str(payload.get("display_name")) or _clean_str(payload.get("name"))
|
||||
return name or "Unknown Commander"
|
||||
|
||||
|
||||
def _commander_key(payload: Mapping[str, object]) -> str:
|
||||
return _normalize_token(_commander_name(payload))
|
||||
|
||||
|
||||
def _sequence(payload: Mapping[str, object], key: str) -> tuple[str, ...]:
|
||||
raw = payload.get(key)
|
||||
if raw is None:
|
||||
return tuple()
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return tuple(_clean_str(item) for item in raw if _clean_str(item))
|
||||
return tuple(filter(None, (_clean_str(raw),)))
|
||||
|
||||
|
||||
_EXCLUDED_THEME_TOKENS = {
|
||||
"legends matter",
|
||||
"historics matter",
|
||||
"partner",
|
||||
"partner - survivors",
|
||||
}
|
||||
|
||||
|
||||
def _theme_should_be_excluded(theme: str) -> bool:
|
||||
token = _normalize_token(theme)
|
||||
if not token:
|
||||
return False
|
||||
if token in _EXCLUDED_THEME_TOKENS:
|
||||
return True
|
||||
return "kindred" in token
|
||||
|
||||
|
||||
def is_noise_theme(theme: str | None) -> bool:
|
||||
"""Return True when the provided theme is considered too generic/noisy.
|
||||
|
||||
The partner suggestion UI should suppress these themes from overlap summaries to
|
||||
keep recommendations focused on distinctive archetypes.
|
||||
"""
|
||||
|
||||
if theme is None:
|
||||
return False
|
||||
return _theme_should_be_excluded(theme)
|
||||
|
||||
|
||||
def _theme_sequence(payload: Mapping[str, object], key: str = "themes") -> tuple[str, ...]:
|
||||
return tuple(
|
||||
theme
|
||||
for theme in _sequence(payload, key)
|
||||
if not _theme_should_be_excluded(theme)
|
||||
)
|
||||
|
||||
|
||||
def _normalize_string_set(values: Iterable[str]) -> tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
collected: list[str] = []
|
||||
for value in values:
|
||||
token = _clean_str(value)
|
||||
if not token:
|
||||
continue
|
||||
key = token.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
collected.append(token)
|
||||
return tuple(collected)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreWeights:
|
||||
"""Weight multipliers for each scoring component."""
|
||||
|
||||
overlap: float
|
||||
synergy: float
|
||||
color: float
|
||||
affinity: float
|
||||
penalty: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreResult:
|
||||
"""Result returned by :func:`score_partner_candidate`."""
|
||||
|
||||
score: float
|
||||
mode: PartnerMode
|
||||
components: Mapping[str, float]
|
||||
notes: tuple[str, ...]
|
||||
weights: ScoreWeights
|
||||
|
||||
|
||||
class PartnerSuggestionContext:
|
||||
"""Container for suggestion dataset fragments used during scoring."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
theme_cooccurrence: Mapping[str, Mapping[str, int]] | None = None,
|
||||
pairing_counts: Mapping[tuple[str, str, str], int] | None = None,
|
||||
curated_synergy: Mapping[tuple[str, str], float] | None = None,
|
||||
) -> None:
|
||||
self._theme_cooccurrence: Dict[str, Dict[str, float]] = {}
|
||||
self._pairing_counts: Dict[tuple[str, str, str], float] = {}
|
||||
self._curated_synergy: Dict[tuple[str, str], float] = {}
|
||||
|
||||
max_co = 0
|
||||
if theme_cooccurrence:
|
||||
for theme, neighbors in theme_cooccurrence.items():
|
||||
theme_key = _normalize_token(theme)
|
||||
if not theme_key:
|
||||
continue
|
||||
store: Dict[str, float] = {}
|
||||
for other, count in neighbors.items():
|
||||
other_key = _normalize_token(other)
|
||||
if not other_key:
|
||||
continue
|
||||
value = float(count or 0)
|
||||
if value <= 0:
|
||||
continue
|
||||
store[other_key] = value
|
||||
max_co = max(max_co, value)
|
||||
if store:
|
||||
self._theme_cooccurrence[theme_key] = store
|
||||
self._theme_co_max = max(max_co, 1.0)
|
||||
|
||||
max_pair = 0
|
||||
if pairing_counts:
|
||||
for key, count in pairing_counts.items():
|
||||
if not isinstance(key, tuple) or len(key) != 3:
|
||||
continue
|
||||
mode, primary, secondary = key
|
||||
norm_key = (
|
||||
_normalize_token(mode),
|
||||
_normalize_token(primary),
|
||||
_normalize_token(secondary),
|
||||
)
|
||||
value = float(count or 0)
|
||||
if value <= 0:
|
||||
continue
|
||||
self._pairing_counts[norm_key] = value
|
||||
# Store symmetric entry to simplify lookups.
|
||||
symmetric = (
|
||||
_normalize_token(mode),
|
||||
_normalize_token(secondary),
|
||||
_normalize_token(primary),
|
||||
)
|
||||
self._pairing_counts[symmetric] = value
|
||||
max_pair = max(max_pair, value)
|
||||
self._pairing_max = max(max_pair, 1.0)
|
||||
|
||||
if curated_synergy:
|
||||
for key, value in curated_synergy.items():
|
||||
if not isinstance(key, tuple) or len(key) != 2:
|
||||
continue
|
||||
primary, secondary = key
|
||||
normalized = (
|
||||
_normalize_token(primary),
|
||||
_normalize_token(secondary),
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
magnitude = max(0.0, float(value))
|
||||
if magnitude <= 0:
|
||||
continue
|
||||
self._curated_synergy[normalized] = min(1.0, magnitude)
|
||||
self._curated_synergy[(normalized[1], normalized[0])] = min(1.0, magnitude)
|
||||
|
||||
@classmethod
|
||||
def from_dataset(cls, payload: Mapping[str, object] | None) -> "PartnerSuggestionContext":
|
||||
if not payload:
|
||||
return cls()
|
||||
|
||||
themes_raw = payload.get("themes")
|
||||
theme_cooccurrence: Dict[str, Dict[str, int]] = {}
|
||||
if isinstance(themes_raw, Mapping):
|
||||
for theme_key, entry in themes_raw.items():
|
||||
co = entry.get("co_occurrence") if isinstance(entry, Mapping) else None
|
||||
if not isinstance(co, Mapping):
|
||||
continue
|
||||
inner: Dict[str, int] = {}
|
||||
for other, info in co.items():
|
||||
if isinstance(info, Mapping):
|
||||
count = info.get("count")
|
||||
else:
|
||||
count = info
|
||||
try:
|
||||
inner[str(other)] = int(count)
|
||||
except Exception:
|
||||
continue
|
||||
theme_cooccurrence[str(theme_key)] = inner
|
||||
|
||||
pairings = payload.get("pairings")
|
||||
pairing_counts: Dict[tuple[str, str, str], int] = {}
|
||||
if isinstance(pairings, Mapping):
|
||||
records = pairings.get("records")
|
||||
if isinstance(records, Sequence):
|
||||
for entry in records:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
mode = str(entry.get("mode", "unknown"))
|
||||
primary = str(entry.get("primary_canonical") or entry.get("primary") or "")
|
||||
secondary = str(entry.get("secondary_canonical") or entry.get("secondary") or "")
|
||||
if not primary or not secondary:
|
||||
continue
|
||||
try:
|
||||
count = int(entry.get("count", 0))
|
||||
except Exception:
|
||||
continue
|
||||
pairing_counts[(mode, primary, secondary)] = count
|
||||
|
||||
curated = payload.get("curated_overrides")
|
||||
curated_synergy: Dict[tuple[str, str], float] = {}
|
||||
if isinstance(curated, Mapping):
|
||||
entries = curated.get("entries")
|
||||
if isinstance(entries, Mapping):
|
||||
for raw_key, raw_value in entries.items():
|
||||
if not isinstance(raw_key, str):
|
||||
continue
|
||||
parts = [part.strip() for part in raw_key.split("::") if part.strip()]
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
try:
|
||||
magnitude = float(raw_value)
|
||||
except Exception:
|
||||
continue
|
||||
curated_synergy[(parts[0], parts[1])] = magnitude
|
||||
|
||||
return cls(
|
||||
theme_cooccurrence=theme_cooccurrence,
|
||||
pairing_counts=pairing_counts,
|
||||
curated_synergy=curated_synergy,
|
||||
)
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def theme_synergy(self, theme_a: str, theme_b: str) -> float:
|
||||
key_a = _normalize_token(theme_a)
|
||||
key_b = _normalize_token(theme_b)
|
||||
if not key_a or not key_b or key_a == key_b:
|
||||
return 0.0
|
||||
co = self._theme_cooccurrence.get(key_a, {})
|
||||
value = co.get(key_b, 0.0)
|
||||
normalized = value / self._theme_co_max
|
||||
curated = self._curated_synergy.get((key_a, key_b), 0.0)
|
||||
return max(0.0, min(1.0, max(normalized, curated)))
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def pairing_strength(self, mode: PartnerMode, primary: str, secondary: str) -> float:
|
||||
key = (
|
||||
mode.value,
|
||||
_normalize_token(primary),
|
||||
_normalize_token(secondary),
|
||||
)
|
||||
value = self._pairing_counts.get(key, 0.0)
|
||||
return max(0.0, min(1.0, value / self._pairing_max))
|
||||
|
||||
|
||||
DEFAULT_WEIGHTS = ScoreWeights(
|
||||
overlap=0.45,
|
||||
synergy=0.25,
|
||||
color=0.15,
|
||||
affinity=0.10,
|
||||
penalty=0.20,
|
||||
)
|
||||
|
||||
|
||||
MODE_WEIGHTS: Mapping[PartnerMode, ScoreWeights] = {
|
||||
PartnerMode.PARTNER: DEFAULT_WEIGHTS,
|
||||
PartnerMode.PARTNER_WITH: ScoreWeights(overlap=0.40, synergy=0.20, color=0.10, affinity=0.20, penalty=0.25),
|
||||
PartnerMode.BACKGROUND: ScoreWeights(overlap=0.50, synergy=0.30, color=0.10, affinity=0.10, penalty=0.25),
|
||||
PartnerMode.DOCTOR_COMPANION: ScoreWeights(overlap=0.30, synergy=0.20, color=0.10, affinity=0.30, penalty=0.25),
|
||||
PartnerMode.NONE: DEFAULT_WEIGHTS,
|
||||
}
|
||||
|
||||
|
||||
def _clamp(value: float, minimum: float = 0.0, maximum: float = 1.0) -> float:
|
||||
if value < minimum:
|
||||
return minimum
|
||||
if value > maximum:
|
||||
return maximum
|
||||
return value
|
||||
|
||||
|
||||
def score_partner_candidate(
|
||||
primary: Mapping[str, object],
|
||||
candidate: Mapping[str, object],
|
||||
*,
|
||||
mode: PartnerMode | str | None = None,
|
||||
context: PartnerSuggestionContext | None = None,
|
||||
) -> ScoreResult:
|
||||
"""Score a partner/background candidate for the provided primary.
|
||||
|
||||
Args:
|
||||
primary: Commander metadata dictionary (as produced by the dataset).
|
||||
candidate: Potential partner/background metadata dictionary.
|
||||
mode: Desired partner mode (auto-detected when omitted).
|
||||
context: Optional suggestion context providing theme/pairing statistics.
|
||||
|
||||
Returns:
|
||||
ScoreResult with aggregate score ``0.0`` – ``1.0`` and component details.
|
||||
"""
|
||||
|
||||
mode = _resolve_mode(primary, candidate, mode)
|
||||
weights = MODE_WEIGHTS.get(mode, DEFAULT_WEIGHTS)
|
||||
ctx = context or PartnerSuggestionContext()
|
||||
|
||||
overlap = _theme_overlap(primary, candidate)
|
||||
synergy = _theme_synergy(primary, candidate, ctx)
|
||||
color_value = _color_compatibility(primary, candidate)
|
||||
affinity, affinity_notes, affinity_penalties = _mechanic_affinity(primary, candidate, mode, ctx)
|
||||
penalty_value, penalty_notes = _collect_penalties(primary, candidate, mode, affinity_penalties)
|
||||
|
||||
positive_total = weights.overlap + weights.synergy + weights.color + weights.affinity
|
||||
positive_total = positive_total or 1.0
|
||||
blended = (
|
||||
weights.overlap * overlap
|
||||
+ weights.synergy * synergy
|
||||
+ weights.color * color_value
|
||||
+ weights.affinity * affinity
|
||||
) / positive_total
|
||||
|
||||
adjusted = blended - weights.penalty * penalty_value
|
||||
final_score = _clamp(adjusted)
|
||||
|
||||
notes = tuple(note for note in (*affinity_notes, *penalty_notes) if note)
|
||||
components = {
|
||||
"overlap": overlap,
|
||||
"synergy": synergy,
|
||||
"color": color_value,
|
||||
"affinity": affinity,
|
||||
"penalty": penalty_value,
|
||||
}
|
||||
|
||||
return ScoreResult(
|
||||
score=final_score,
|
||||
mode=mode,
|
||||
components=components,
|
||||
notes=notes,
|
||||
weights=weights,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_mode(
|
||||
primary: Mapping[str, object],
|
||||
candidate: Mapping[str, object],
|
||||
provided: PartnerMode | str | None,
|
||||
) -> PartnerMode:
|
||||
if isinstance(provided, PartnerMode):
|
||||
return provided
|
||||
if isinstance(provided, str) and provided:
|
||||
normalized = provided.replace("-", "_").strip().casefold()
|
||||
for mode in PartnerMode:
|
||||
if mode.value == normalized:
|
||||
return mode
|
||||
|
||||
partner_meta_primary = _partner_meta(primary)
|
||||
partner_meta_candidate = _partner_meta(candidate)
|
||||
candidate_name = _commander_name(candidate)
|
||||
|
||||
if partner_meta_candidate.get("is_background"):
|
||||
return PartnerMode.BACKGROUND
|
||||
partner_with = {
|
||||
_normalize_token(name)
|
||||
for name in partner_meta_primary.get("partner_with", [])
|
||||
}
|
||||
if partner_with and _normalize_token(candidate_name) in partner_with:
|
||||
return PartnerMode.PARTNER_WITH
|
||||
if partner_meta_primary.get("is_doctor") and partner_meta_candidate.get("is_doctors_companion"):
|
||||
return PartnerMode.DOCTOR_COMPANION
|
||||
if partner_meta_primary.get("is_doctors_companion") and partner_meta_candidate.get("is_doctor"):
|
||||
return PartnerMode.DOCTOR_COMPANION
|
||||
if partner_meta_primary.get("has_partner") and partner_meta_candidate.get("has_partner"):
|
||||
return PartnerMode.PARTNER
|
||||
if partner_meta_candidate.get("supports_backgrounds") and partner_meta_primary.get("is_background"):
|
||||
return PartnerMode.BACKGROUND
|
||||
if partner_meta_candidate.get("has_partner"):
|
||||
return PartnerMode.PARTNER
|
||||
return PartnerMode.PARTNER
|
||||
|
||||
|
||||
def _partner_meta(payload: Mapping[str, object]) -> MutableMapping[str, object]:
|
||||
meta = payload.get("partner")
|
||||
if isinstance(meta, Mapping):
|
||||
return dict(meta)
|
||||
return {}
|
||||
|
||||
|
||||
def _theme_overlap(primary: Mapping[str, object], candidate: Mapping[str, object]) -> float:
|
||||
theme_primary = {
|
||||
_normalize_token(theme)
|
||||
for theme in _theme_sequence(primary)
|
||||
}
|
||||
theme_candidate = {
|
||||
_normalize_token(theme)
|
||||
for theme in _theme_sequence(candidate)
|
||||
}
|
||||
theme_primary.discard("")
|
||||
theme_candidate.discard("")
|
||||
|
||||
role_primary = {
|
||||
_normalize_token(tag)
|
||||
for tag in _sequence(primary, "role_tags")
|
||||
}
|
||||
role_candidate = {
|
||||
_normalize_token(tag)
|
||||
for tag in _sequence(candidate, "role_tags")
|
||||
}
|
||||
role_primary.discard("")
|
||||
role_candidate.discard("")
|
||||
|
||||
# Base Jaccard over theme tags.
|
||||
union = theme_primary | theme_candidate
|
||||
if not union:
|
||||
base = 0.0
|
||||
else:
|
||||
base = len(theme_primary & theme_candidate) / len(union)
|
||||
|
||||
# Role-aware bonus (weighted at 30% of overlap component).
|
||||
role_union = role_primary | role_candidate
|
||||
if not role_union:
|
||||
role_score = 0.0
|
||||
else:
|
||||
role_score = len(role_primary & role_candidate) / len(role_union)
|
||||
|
||||
combined = 0.7 * base + 0.3 * role_score
|
||||
return _clamp(combined)
|
||||
|
||||
|
||||
def _theme_synergy(
|
||||
primary: Mapping[str, object],
|
||||
candidate: Mapping[str, object],
|
||||
context: PartnerSuggestionContext,
|
||||
) -> float:
|
||||
themes_primary = _theme_sequence(primary)
|
||||
themes_candidate = _theme_sequence(candidate)
|
||||
if not themes_primary or not themes_candidate:
|
||||
return 0.0
|
||||
|
||||
total = 0.0
|
||||
weight = 0
|
||||
for theme_a in themes_primary:
|
||||
for theme_b in themes_candidate:
|
||||
value = context.theme_synergy(theme_a, theme_b)
|
||||
if value <= 0:
|
||||
continue
|
||||
total += value
|
||||
weight += 1
|
||||
|
||||
if weight == 0:
|
||||
return 0.0
|
||||
|
||||
average = total / weight
|
||||
|
||||
# Observed pairing signal augments synergy.
|
||||
primary_name = _commander_name(primary)
|
||||
candidate_name = _commander_name(candidate)
|
||||
observed_partner = context.pairing_strength(PartnerMode.PARTNER, primary_name, candidate_name)
|
||||
observed_background = context.pairing_strength(PartnerMode.BACKGROUND, primary_name, candidate_name)
|
||||
observed_doctor = context.pairing_strength(PartnerMode.DOCTOR_COMPANION, primary_name, candidate_name)
|
||||
observed_any = max(observed_partner, observed_background, observed_doctor)
|
||||
|
||||
return _clamp(max(average, observed_any))
|
||||
|
||||
|
||||
def _color_compatibility(primary: Mapping[str, object], candidate: Mapping[str, object]) -> float:
|
||||
primary_colors = {
|
||||
_clean_str(color).upper()
|
||||
for color in _sequence(primary, "color_identity")
|
||||
}
|
||||
candidate_colors = {
|
||||
_clean_str(color).upper()
|
||||
for color in _sequence(candidate, "color_identity")
|
||||
}
|
||||
|
||||
if not candidate_colors:
|
||||
# Colorless partners still provide value when primary is colored.
|
||||
return 0.6 if primary_colors else 0.0
|
||||
|
||||
overlap = primary_colors & candidate_colors
|
||||
union = primary_colors | candidate_colors
|
||||
overlap_ratio = len(overlap) / max(len(candidate_colors), 1)
|
||||
|
||||
added_colors = len(union) - len(primary_colors)
|
||||
if added_colors <= 0:
|
||||
delta = 1.0
|
||||
elif added_colors == 1:
|
||||
delta = 0.75
|
||||
elif added_colors == 2:
|
||||
delta = 0.45
|
||||
else:
|
||||
delta = 0.20
|
||||
|
||||
colorless_bonus = 0.1 if candidate_colors == {"C"} else 0.0
|
||||
|
||||
blended = 0.6 * overlap_ratio + 0.4 * delta + colorless_bonus
|
||||
return _clamp(blended)
|
||||
|
||||
|
||||
def _mechanic_affinity(
|
||||
primary: Mapping[str, object],
|
||||
candidate: Mapping[str, object],
|
||||
mode: PartnerMode,
|
||||
context: PartnerSuggestionContext,
|
||||
) -> tuple[float, list[str], list[tuple[str, float]]]:
|
||||
primary_meta = _partner_meta(primary)
|
||||
candidate_meta = _partner_meta(candidate)
|
||||
primary_name = _commander_name(primary)
|
||||
candidate_name = _commander_name(candidate)
|
||||
|
||||
notes: list[str] = []
|
||||
penalties: list[tuple[str, float]] = []
|
||||
score = 0.0
|
||||
|
||||
if mode is PartnerMode.PARTNER_WITH:
|
||||
partner_with = {
|
||||
_normalize_token(name)
|
||||
for name in primary_meta.get("partner_with", [])
|
||||
}
|
||||
if partner_with and _normalize_token(candidate_name) in partner_with:
|
||||
score = 1.0
|
||||
notes.append("partner_with_match")
|
||||
else:
|
||||
penalties.append(("missing_partner_with_link", 0.9))
|
||||
|
||||
elif mode is PartnerMode.BACKGROUND:
|
||||
if candidate_meta.get("is_background") and primary_meta.get("supports_backgrounds"):
|
||||
score = 0.9
|
||||
notes.append("background_compatible")
|
||||
else:
|
||||
if not candidate_meta.get("is_background"):
|
||||
penalties.append(("candidate_not_background", 1.0))
|
||||
if not primary_meta.get("supports_backgrounds"):
|
||||
penalties.append(("primary_cannot_use_background", 1.0))
|
||||
|
||||
elif mode is PartnerMode.DOCTOR_COMPANION:
|
||||
primary_is_doctor = bool(primary_meta.get("is_doctor"))
|
||||
primary_is_companion = bool(primary_meta.get("is_doctors_companion"))
|
||||
candidate_is_doctor = bool(candidate_meta.get("is_doctor"))
|
||||
candidate_is_companion = bool(candidate_meta.get("is_doctors_companion"))
|
||||
|
||||
if primary_is_doctor and candidate_is_companion:
|
||||
score = 1.0
|
||||
notes.append("doctor_companion_match")
|
||||
elif primary_is_companion and candidate_is_doctor:
|
||||
score = 1.0
|
||||
notes.append("doctor_companion_match")
|
||||
else:
|
||||
penalties.append(("doctor_pairing_illegal", 1.0))
|
||||
|
||||
else: # Partner-style default
|
||||
if primary_meta.get("has_partner") and candidate_meta.get("has_partner"):
|
||||
score = 0.6
|
||||
notes.append("shared_partner_keyword")
|
||||
else:
|
||||
penalties.append(("missing_partner_keyword", 1.0))
|
||||
|
||||
primary_labels = {
|
||||
_normalize_token(label)
|
||||
for label in _sequence(primary_meta, "restricted_partner_labels")
|
||||
}
|
||||
candidate_labels = {
|
||||
_normalize_token(label)
|
||||
for label in _sequence(candidate_meta, "restricted_partner_labels")
|
||||
}
|
||||
shared_labels = primary_labels & candidate_labels
|
||||
if primary_labels or candidate_labels:
|
||||
if shared_labels:
|
||||
score = max(score, 0.85)
|
||||
notes.append("restricted_label_match")
|
||||
else:
|
||||
penalties.append(("restricted_label_mismatch", 0.7))
|
||||
|
||||
observed = context.pairing_strength(mode, primary_name, candidate_name)
|
||||
if observed > 0:
|
||||
score = max(score, observed)
|
||||
notes.append("observed_pairing")
|
||||
|
||||
return _clamp(score), notes, penalties
|
||||
|
||||
|
||||
def _collect_penalties(
|
||||
primary: Mapping[str, object],
|
||||
candidate: Mapping[str, object],
|
||||
mode: PartnerMode,
|
||||
extra: Iterable[tuple[str, float]],
|
||||
) -> tuple[float, list[str]]:
|
||||
penalties: list[tuple[str, float]] = list(extra)
|
||||
|
||||
themes_primary_raw = _sequence(primary, "themes")
|
||||
themes_candidate_raw = _sequence(candidate, "themes")
|
||||
themes_primary = _theme_sequence(primary)
|
||||
themes_candidate = _theme_sequence(candidate)
|
||||
if (not themes_primary or not themes_candidate) and (not themes_primary_raw or not themes_candidate_raw):
|
||||
penalties.append(("missing_theme_metadata", 0.5))
|
||||
|
||||
if mode is PartnerMode.PARTNER_WITH:
|
||||
partner_with = {
|
||||
_normalize_token(name)
|
||||
for name in _sequence(primary.get("partner", {}), "partner_with")
|
||||
}
|
||||
if not partner_with:
|
||||
penalties.append(("primary_missing_partner_with", 0.7))
|
||||
|
||||
colors_candidate = set(_sequence(candidate, "color_identity"))
|
||||
if len(colors_candidate) >= 4:
|
||||
penalties.append(("candidate_color_spread", 0.25))
|
||||
|
||||
total = 0.0
|
||||
reasons: list[str] = []
|
||||
for reason, magnitude in penalties:
|
||||
if magnitude <= 0:
|
||||
continue
|
||||
total += magnitude
|
||||
reasons.append(reason)
|
||||
|
||||
return _clamp(total), reasons
|
||||
|
|
@ -10,6 +10,8 @@ __all__ = [
|
|||
"get_mdfc_metrics",
|
||||
"record_theme_summary",
|
||||
"get_theme_metrics",
|
||||
"record_partner_summary",
|
||||
"get_partner_metrics",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -34,6 +36,14 @@ _theme_metrics: Dict[str, Any] = {
|
|||
_user_theme_counter: Counter[str] = Counter()
|
||||
_user_theme_labels: Dict[str, str] = {}
|
||||
|
||||
_partner_metrics: Dict[str, Any] = {
|
||||
"total_pairs": 0,
|
||||
"mode_counts": {},
|
||||
"last_summary": None,
|
||||
"last_updated": None,
|
||||
"last_updated_iso": None,
|
||||
}
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
try:
|
||||
|
|
@ -143,6 +153,15 @@ def _reset_metrics_for_test() -> None:
|
|||
)
|
||||
_user_theme_counter.clear()
|
||||
_user_theme_labels.clear()
|
||||
_partner_metrics.update(
|
||||
{
|
||||
"total_pairs": 0,
|
||||
"mode_counts": {},
|
||||
"last_summary": None,
|
||||
"last_updated": None,
|
||||
"last_updated_iso": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_theme_list(values: Iterable[Any]) -> list[str]:
|
||||
|
|
@ -239,3 +258,57 @@ def get_theme_metrics() -> Dict[str, Any]:
|
|||
"last_updated": _theme_metrics.get("last_updated_iso"),
|
||||
"top_user_themes": top_user,
|
||||
}
|
||||
|
||||
|
||||
def record_partner_summary(commander_summary: Dict[str, Any] | None) -> None:
|
||||
if not isinstance(commander_summary, dict):
|
||||
return
|
||||
|
||||
combined = commander_summary.get("combined")
|
||||
if not isinstance(combined, dict):
|
||||
return
|
||||
|
||||
mode = str(commander_summary.get("partner_mode") or combined.get("partner_mode") or "none")
|
||||
primary = commander_summary.get("primary")
|
||||
secondary = commander_summary.get("secondary")
|
||||
names = commander_summary.get("names")
|
||||
color_identity_raw = combined.get("color_identity")
|
||||
if isinstance(color_identity_raw, (list, tuple)):
|
||||
colors = [str(c).strip().upper() for c in color_identity_raw if str(c).strip()]
|
||||
else:
|
||||
colors = []
|
||||
entry = {
|
||||
"primary": primary,
|
||||
"secondary": secondary,
|
||||
"names": list(names or []) if isinstance(names, (list, tuple)) else names,
|
||||
"partner_mode": mode,
|
||||
"color_identity": colors,
|
||||
"color_code": combined.get("color_code"),
|
||||
"color_label": combined.get("color_label"),
|
||||
"color_sources": combined.get("color_sources"),
|
||||
"color_delta": combined.get("color_delta"),
|
||||
"secondary_role": combined.get("secondary_role"),
|
||||
"secondary_role_label": combined.get("secondary_role_label"),
|
||||
}
|
||||
|
||||
timestamp = time.time()
|
||||
iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(timestamp))
|
||||
mode_key = mode.lower() if isinstance(mode, str) else "none"
|
||||
|
||||
with _lock:
|
||||
_partner_metrics["total_pairs"] = int(_partner_metrics.get("total_pairs", 0) or 0) + 1
|
||||
mode_counts = _partner_metrics.setdefault("mode_counts", {})
|
||||
mode_counts[mode_key] = int(mode_counts.get(mode_key, 0) or 0) + 1
|
||||
_partner_metrics["last_summary"] = entry
|
||||
_partner_metrics["last_updated"] = timestamp
|
||||
_partner_metrics["last_updated_iso"] = iso
|
||||
|
||||
|
||||
def get_partner_metrics() -> Dict[str, Any]:
|
||||
with _lock:
|
||||
return {
|
||||
"total_pairs": int(_partner_metrics.get("total_pairs", 0) or 0),
|
||||
"mode_counts": dict(_partner_metrics.get("mode_counts", {})),
|
||||
"last_summary": _partner_metrics.get("last_summary"),
|
||||
"last_updated": _partner_metrics.get("last_updated_iso"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -585,6 +585,14 @@ class CommanderThemeError(CommanderValidationError):
|
|||
"""
|
||||
super().__init__(message, code="CMD_THEME_ERR", details=details)
|
||||
|
||||
|
||||
class CommanderPartnerError(CommanderValidationError):
|
||||
"""Raised when partner or background pairing validation fails."""
|
||||
|
||||
def __init__(self, message: str, details: dict | None = None):
|
||||
super().__init__(message, details=details)
|
||||
self.code = "CMD_PARTNER_ERR"
|
||||
|
||||
class CommanderMoveError(DeckBuilderError):
|
||||
"""Raised when there are issues moving the commander to the top of the library.
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,27 @@ from exceptions import (
|
|||
CommanderValidationError,
|
||||
MTGJSONDownloadError
|
||||
)
|
||||
from scripts import generate_background_cards as background_cards_script
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _generate_background_catalog(cards_path: str, output_path: str) -> None:
|
||||
"""Regenerate ``background_cards.csv`` from the latest cards dataset."""
|
||||
|
||||
logger.info('Generating background cards catalog')
|
||||
args = [
|
||||
'--source', cards_path,
|
||||
'--output', output_path,
|
||||
]
|
||||
try:
|
||||
background_cards_script.main(args)
|
||||
except Exception: # pragma: no cover - surfaced to caller/test
|
||||
logger.exception('Failed to generate background catalog')
|
||||
raise
|
||||
else:
|
||||
logger.info('Background cards catalog generated successfully')
|
||||
|
||||
# Create logger for this module
|
||||
logger = logging_util.logging.getLogger(__name__)
|
||||
|
|
@ -142,8 +163,12 @@ def determine_commanders() -> None:
|
|||
|
||||
# Save commander cards
|
||||
logger.info('Saving validated commander cards')
|
||||
filtered_df.to_csv(f'{CSV_DIRECTORY}/commander_cards.csv', index=False)
|
||||
|
||||
commander_path = f'{CSV_DIRECTORY}/commander_cards.csv'
|
||||
filtered_df.to_csv(commander_path, index=False)
|
||||
|
||||
background_output = f'{CSV_DIRECTORY}/background_cards.csv'
|
||||
_generate_background_catalog(cards_file, background_output)
|
||||
|
||||
logger.info('Commander card generation completed successfully')
|
||||
|
||||
except (CSVFileNotFoundError, MTGJSONDownloadError) as e:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||
|
||||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder import builder_constants as bc
|
||||
from deck_builder.partner_selection import apply_partner_inputs
|
||||
from deck_builder.theme_resolution import (
|
||||
ThemeResolutionInfo,
|
||||
clean_theme_inputs,
|
||||
|
|
@ -205,8 +206,16 @@ def run(
|
|||
theme_match_mode: str = "permissive",
|
||||
user_theme_resolution: Optional[ThemeResolutionInfo] = None,
|
||||
user_theme_weight: Optional[float] = None,
|
||||
secondary_commander: Optional[str] = None,
|
||||
background: Optional[str] = None,
|
||||
enable_partner_mechanics: bool = False,
|
||||
) -> DeckBuilder:
|
||||
"""Run a scripted non-interactive deck build and return the DeckBuilder instance."""
|
||||
"""Run a scripted non-interactive deck build and return the DeckBuilder instance.
|
||||
|
||||
When ``enable_partner_mechanics`` is True, optional ``secondary_commander``
|
||||
or ``background`` inputs are resolved into a combined commander pairing
|
||||
before any deck-building steps execute.
|
||||
"""
|
||||
trimmed_commander = (command_name or "").strip()
|
||||
if trimmed_commander:
|
||||
_validate_commander_available(trimmed_commander)
|
||||
|
|
@ -276,6 +285,27 @@ def run(
|
|||
builder.headless = True # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
partner_feature_enabled = bool(enable_partner_mechanics)
|
||||
secondary_clean = (secondary_commander or "").strip()
|
||||
background_clean = (background or "").strip()
|
||||
try:
|
||||
builder.partner_feature_enabled = partner_feature_enabled # type: ignore[attr-defined]
|
||||
builder.requested_secondary_commander = secondary_clean or None # type: ignore[attr-defined]
|
||||
builder.requested_background = background_clean or None # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if partner_feature_enabled and trimmed_commander:
|
||||
combined_result = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name=trimmed_commander,
|
||||
secondary_name=secondary_clean or None,
|
||||
background_name=background_clean or None,
|
||||
feature_enabled=True,
|
||||
)
|
||||
if combined_result is not None:
|
||||
_apply_combined_commander_to_builder(builder, combined_result)
|
||||
|
||||
# Configure include/exclude settings (M1: Config + Validation + Persistence)
|
||||
try:
|
||||
|
|
@ -480,6 +510,39 @@ def _print_include_exclude_summary(builder: DeckBuilder) -> None:
|
|||
print("=" * 50)
|
||||
|
||||
|
||||
def _apply_combined_commander_to_builder(builder: DeckBuilder, combined_commander: Any) -> None:
|
||||
"""Attach combined commander metadata to the builder for downstream use."""
|
||||
|
||||
try:
|
||||
builder.combined_commander = combined_commander # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
builder.partner_mode = combined_commander.partner_mode # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
builder.secondary_commander = combined_commander.secondary_name # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
builder.combined_color_identity = combined_commander.color_identity # type: ignore[attr-defined]
|
||||
builder.combined_theme_tags = combined_commander.theme_tags # type: ignore[attr-defined]
|
||||
builder.partner_warnings = combined_commander.warnings # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
commander_dict = getattr(builder, "commander_dict", None)
|
||||
if isinstance(commander_dict, dict):
|
||||
try:
|
||||
commander_dict["Partner Mode"] = combined_commander.partner_mode.value
|
||||
commander_dict["Secondary Commander"] = combined_commander.secondary_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _export_outputs(builder: DeckBuilder) -> None:
|
||||
# M4: Print include/exclude summary to console
|
||||
_print_include_exclude_summary(builder)
|
||||
|
|
@ -550,6 +613,13 @@ def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]:
|
|||
return None
|
||||
|
||||
|
||||
def _parse_bool_cli(val: str) -> bool:
|
||||
result = _parse_bool(val)
|
||||
if result is None:
|
||||
raise argparse.ArgumentTypeError(f"Expected a boolean value, received '{val}'")
|
||||
return result
|
||||
|
||||
|
||||
def _parse_card_list(val: Optional[str]) -> List[str]:
|
||||
"""Parse comma or semicolon-separated card list from CLI argument."""
|
||||
if not val:
|
||||
|
|
@ -1166,6 +1236,12 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|||
help="Path to JSON config file (string)")
|
||||
p.add_argument("--commander", metavar="NAME", default=None,
|
||||
help="Commander name to search for (string)")
|
||||
p.add_argument("--secondary-commander", metavar="NAME", default=None,
|
||||
help="Secondary commander name when using Partner/Partner With mechanics")
|
||||
p.add_argument("--background", metavar="NAME", default=None,
|
||||
help="Background card name when choosing a Background")
|
||||
p.add_argument("--enable-partner-mechanics", metavar="BOOL", type=_parse_bool_cli, default=None,
|
||||
help="Enable partner/background mechanics for this run (bool: true/false/1/0)")
|
||||
p.add_argument("--primary-choice", metavar="INT", type=int, default=None,
|
||||
help="Primary theme tag choice number (integer)")
|
||||
p.add_argument("--secondary-choice", metavar="INT", type=_parse_opt_int, default=None,
|
||||
|
|
@ -1397,6 +1473,49 @@ def _resolve_value(
|
|||
return default
|
||||
|
||||
|
||||
def _resolve_string_option(
|
||||
cli_value: Optional[str], env_name: str, json_data: Dict[str, Any], json_key: str
|
||||
) -> Optional[str]:
|
||||
if cli_value is not None:
|
||||
text = str(cli_value).strip()
|
||||
return text or None
|
||||
|
||||
env_val = os.getenv(env_name)
|
||||
if env_val:
|
||||
text = env_val.strip()
|
||||
if text:
|
||||
return text
|
||||
|
||||
raw = json_data.get(json_key)
|
||||
if raw is not None:
|
||||
text = str(raw).strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_bool_option(
|
||||
cli_value: Optional[bool], env_name: str, json_data: Dict[str, Any], json_key: str
|
||||
) -> Optional[bool]:
|
||||
if cli_value is not None:
|
||||
return bool(cli_value)
|
||||
|
||||
env_val = os.getenv(env_name)
|
||||
if env_val is not None:
|
||||
parsed = _parse_bool(env_val)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
|
||||
raw = json_data.get(json_key)
|
||||
if raw is not None:
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
parsed = _parse_bool(str(raw))
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
_ensure_data_ready()
|
||||
parser = _build_arg_parser()
|
||||
|
|
@ -1643,6 +1762,25 @@ def _main() -> int:
|
|||
print(str(exc))
|
||||
return 2
|
||||
|
||||
resolved_secondary_commander = _resolve_string_option(
|
||||
getattr(args, "secondary_commander", None),
|
||||
"DECK_SECONDARY_COMMANDER",
|
||||
json_cfg,
|
||||
"secondary_commander",
|
||||
)
|
||||
resolved_background = _resolve_string_option(
|
||||
getattr(args, "background", None),
|
||||
"DECK_BACKGROUND",
|
||||
json_cfg,
|
||||
"background",
|
||||
)
|
||||
resolved_partner_flag = _resolve_bool_option(
|
||||
getattr(args, "enable_partner_mechanics", None),
|
||||
"ENABLE_PARTNER_MECHANICS",
|
||||
json_cfg,
|
||||
"enable_partner_mechanics",
|
||||
)
|
||||
|
||||
resolved = {
|
||||
"command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]),
|
||||
"add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]),
|
||||
|
|
@ -1671,6 +1809,9 @@ def _main() -> int:
|
|||
"additional_themes": list(theme_resolution.requested),
|
||||
"theme_match_mode": theme_resolution.mode,
|
||||
"user_theme_weight": weight_value,
|
||||
"secondary_commander": resolved_secondary_commander,
|
||||
"background": resolved_background,
|
||||
"enable_partner_mechanics": bool(resolved_partner_flag) if resolved_partner_flag is not None else False,
|
||||
}
|
||||
|
||||
if args.dry_run:
|
||||
|
|
@ -1706,6 +1847,7 @@ def _main() -> int:
|
|||
try:
|
||||
run_kwargs = dict(resolved)
|
||||
run_kwargs["user_theme_resolution"] = theme_resolution
|
||||
run_kwargs["enable_partner_mechanics"] = bool(resolved_partner_flag)
|
||||
run(**run_kwargs)
|
||||
except CommanderValidationError as exc:
|
||||
print(str(exc))
|
||||
|
|
|
|||
767
code/scripts/build_partner_suggestions.py
Normal file
767
code/scripts/build_partner_suggestions.py
Normal file
|
|
@ -0,0 +1,767 @@
|
|||
"""Aggregate commander partner/background metadata for suggestion scoring.
|
||||
|
||||
This utility ingests the commander catalog and existing deck exports to
|
||||
construct a compact, deterministic dataset that downstream suggestion logic can
|
||||
consume when ranking partner/background pairings. The output is written to
|
||||
``config/analytics/partner_synergy.json`` by default and includes:
|
||||
|
||||
* Commander index with color identity, theme tags, and partner/background flags.
|
||||
* Theme reverse index plus deck tag co-occurrence statistics.
|
||||
* Observed partner/background pairings derived from deck export sidecars.
|
||||
|
||||
The script is intentionally light-weight so it can run as part of CI or ad-hoc
|
||||
refresh workflows. All collections are sorted before serialization to guarantee
|
||||
stable diffs between runs on the same inputs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import ast
|
||||
from collections import defaultdict
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Dict, Iterable, List, MutableMapping, Sequence, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pandas as pd # noqa: E402
|
||||
|
||||
from code.deck_builder.partner_background_utils import analyze_partner_background # noqa: E402
|
||||
|
||||
try: # Soft import to allow tests to override CSV path without settings.
|
||||
from code.deck_builder import builder_constants as _bc
|
||||
except Exception: # pragma: no cover - fallback when builder constants unavailable
|
||||
_bc = None # type: ignore
|
||||
|
||||
DEFAULT_DECK_DIR = ROOT / "deck_files"
|
||||
DEFAULT_OUTPUT_PATH = ROOT / "config" / "analytics" / "partner_synergy.json"
|
||||
DEFAULT_COMMANDER_CSV = (
|
||||
Path(getattr(_bc, "COMMANDER_CSV_PATH", "")) if getattr(_bc, "COMMANDER_CSV_PATH", "") else ROOT / "csv_files" / "commander_cards.csv"
|
||||
)
|
||||
|
||||
_WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G", "C")
|
||||
_COLOR_PRIORITY = {color: index for index, color in enumerate(_WUBRG_ORDER)}
|
||||
|
||||
_ALLOWED_MODES = {
|
||||
"none",
|
||||
"partner",
|
||||
"partner_with",
|
||||
"background",
|
||||
"doctor_companion",
|
||||
"unknown",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_name(value: str | None) -> str:
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def _normalize_bool(value: object) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value in (0, 1):
|
||||
return bool(value)
|
||||
text = str(value or "").strip().casefold()
|
||||
if not text:
|
||||
return False
|
||||
if text in {"1", "true", "t", "yes", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "f", "no", "off"}:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_sequence(value: object) -> Tuple[str, ...]:
|
||||
if value is None:
|
||||
return tuple()
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
items = list(value)
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return tuple()
|
||||
parsed = None
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except Exception:
|
||||
try:
|
||||
parsed = ast.literal_eval(text)
|
||||
except Exception:
|
||||
parsed = None
|
||||
if isinstance(parsed, (list, tuple, set)):
|
||||
items = list(parsed)
|
||||
else:
|
||||
if ";" in text:
|
||||
items = [part.strip() for part in text.split(";")]
|
||||
elif "," in text:
|
||||
items = [part.strip() for part in text.split(",")]
|
||||
else:
|
||||
items = [text]
|
||||
cleaned: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
token = str(item).strip()
|
||||
if not token:
|
||||
continue
|
||||
key = token.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(token)
|
||||
return tuple(cleaned)
|
||||
|
||||
|
||||
def _normalize_color_identity(values: Iterable[str]) -> Tuple[str, ...]:
|
||||
ordered: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
token = str(value or "").strip().upper()
|
||||
if not token:
|
||||
continue
|
||||
if len(token) > 1 and all(ch in _COLOR_PRIORITY for ch in token):
|
||||
for ch in token:
|
||||
if ch not in seen:
|
||||
seen.add(ch)
|
||||
ordered.append(ch)
|
||||
continue
|
||||
if token not in seen:
|
||||
seen.add(token)
|
||||
ordered.append(token)
|
||||
ordered.sort(key=lambda color: (_COLOR_PRIORITY.get(color, len(_COLOR_PRIORITY)), color))
|
||||
return tuple(ordered)
|
||||
|
||||
|
||||
def _normalize_tag(value: str | None) -> Tuple[str, str]:
|
||||
display = str(value or "").strip()
|
||||
return display, display.casefold()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PartnerMetadata:
|
||||
has_partner: bool
|
||||
partner_with: Tuple[str, ...]
|
||||
supports_backgrounds: bool
|
||||
choose_background: bool
|
||||
is_background: bool
|
||||
is_doctor: bool
|
||||
is_doctors_companion: bool
|
||||
has_plain_partner: bool
|
||||
has_restricted_partner: bool
|
||||
restricted_partner_labels: Tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"has_partner": self.has_partner,
|
||||
"partner_with": list(self.partner_with),
|
||||
"supports_backgrounds": self.supports_backgrounds,
|
||||
"choose_background": self.choose_background,
|
||||
"is_background": self.is_background,
|
||||
"is_doctor": self.is_doctor,
|
||||
"is_doctors_companion": self.is_doctors_companion,
|
||||
"has_plain_partner": self.has_plain_partner,
|
||||
"has_restricted_partner": self.has_restricted_partner,
|
||||
"restricted_partner_labels": list(self.restricted_partner_labels),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommanderRecord:
|
||||
key: str
|
||||
name: str
|
||||
display_name: str
|
||||
color_identity: Tuple[str, ...]
|
||||
themes: Tuple[str, ...]
|
||||
role_tags: Tuple[str, ...]
|
||||
partner_metadata: PartnerMetadata
|
||||
usage_primary: int = 0
|
||||
usage_secondary: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"display_name": self.display_name,
|
||||
"color_identity": list(self.color_identity),
|
||||
"themes": list(self.themes),
|
||||
"role_tags": list(self.role_tags),
|
||||
"partner": self.partner_metadata.to_dict(),
|
||||
"usage": {
|
||||
"primary": self.usage_primary,
|
||||
"secondary": self.usage_secondary,
|
||||
"total": self.usage_primary + self.usage_secondary,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeckRecord:
|
||||
deck_id: str
|
||||
commanders: List[str] = field(default_factory=list)
|
||||
partner_mode: str = "none"
|
||||
tags: MutableMapping[str, str] = field(default_factory=dict)
|
||||
sources: set[str] = field(default_factory=set)
|
||||
|
||||
def add_tags(self, tags: Iterable[str]) -> None:
|
||||
for tag in tags:
|
||||
display, canonical = _normalize_tag(tag)
|
||||
if not canonical:
|
||||
continue
|
||||
self.tags.setdefault(canonical, display)
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
cleaned = _normalize_partner_mode(mode)
|
||||
if cleaned and cleaned != "none":
|
||||
self.partner_mode = cleaned
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PairingStat:
|
||||
mode: str
|
||||
primary_key: str
|
||||
primary_name: str
|
||||
secondary_key: str
|
||||
secondary_name: str
|
||||
count: int = 0
|
||||
tags: set[str] = field(default_factory=set)
|
||||
examples: List[str] = field(default_factory=list)
|
||||
|
||||
def add(self, deck_id: str, tags: Iterable[str], max_examples: int) -> None:
|
||||
self.count += 1
|
||||
for tag in tags:
|
||||
self.tags.add(tag)
|
||||
if len(self.examples) < max_examples:
|
||||
self.examples.append(deck_id)
|
||||
|
||||
def to_dict(self, commander_index: Dict[str, CommanderRecord]) -> dict[str, object]:
|
||||
primary_colors = list(commander_index.get(self.primary_key, CommanderRecord(
|
||||
key=self.primary_key,
|
||||
name=self.primary_name,
|
||||
display_name=self.primary_name,
|
||||
color_identity=tuple(),
|
||||
themes=tuple(),
|
||||
role_tags=tuple(),
|
||||
partner_metadata=PartnerMetadata(
|
||||
has_partner=False,
|
||||
partner_with=tuple(),
|
||||
supports_backgrounds=False,
|
||||
choose_background=False,
|
||||
is_background=False,
|
||||
is_doctor=False,
|
||||
is_doctors_companion=False,
|
||||
has_plain_partner=False,
|
||||
has_restricted_partner=False,
|
||||
restricted_partner_labels=tuple(),
|
||||
),
|
||||
)).color_identity)
|
||||
secondary_colors = list(commander_index.get(self.secondary_key, CommanderRecord(
|
||||
key=self.secondary_key,
|
||||
name=self.secondary_name,
|
||||
display_name=self.secondary_name,
|
||||
color_identity=tuple(),
|
||||
themes=tuple(),
|
||||
role_tags=tuple(),
|
||||
partner_metadata=PartnerMetadata(
|
||||
has_partner=False,
|
||||
partner_with=tuple(),
|
||||
supports_backgrounds=False,
|
||||
choose_background=False,
|
||||
is_background=False,
|
||||
is_doctor=False,
|
||||
is_doctors_companion=False,
|
||||
has_plain_partner=False,
|
||||
has_restricted_partner=False,
|
||||
restricted_partner_labels=tuple(),
|
||||
),
|
||||
)).color_identity)
|
||||
combined = sorted(set(primary_colors) | set(secondary_colors), key=lambda c: (_COLOR_PRIORITY.get(c, len(_COLOR_PRIORITY)), c))
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"primary": self.primary_name,
|
||||
"primary_canonical": self.primary_key,
|
||||
"primary_colors": primary_colors,
|
||||
"secondary": self.secondary_name,
|
||||
"secondary_canonical": self.secondary_key,
|
||||
"secondary_colors": secondary_colors,
|
||||
"combined_colors": combined,
|
||||
"count": self.count,
|
||||
"tags": sorted(self.tags, key=lambda t: t.casefold()),
|
||||
"examples": sorted(self.examples),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_partner_mode(value: str | None) -> str:
|
||||
text = str(value or "").strip().replace("-", "_").casefold()
|
||||
if not text:
|
||||
return "none"
|
||||
replacements = {
|
||||
"partner with": "partner_with",
|
||||
"partnerwith": "partner_with",
|
||||
"choose a background": "background",
|
||||
"choose_background": "background",
|
||||
"backgrounds": "background",
|
||||
"background": "background",
|
||||
"doctor's companion": "doctor_companion",
|
||||
"doctors companion": "doctor_companion",
|
||||
"doctor companion": "doctor_companion",
|
||||
}
|
||||
normalized = replacements.get(text, text)
|
||||
if normalized not in _ALLOWED_MODES:
|
||||
if normalized in {"partnerwith"}:
|
||||
normalized = "partner_with"
|
||||
elif normalized.startswith("partner_with"):
|
||||
normalized = "partner_with"
|
||||
elif normalized.startswith("doctor"):
|
||||
normalized = "doctor_companion"
|
||||
elif normalized.startswith("background"):
|
||||
normalized = "background"
|
||||
else:
|
||||
normalized = "unknown"
|
||||
return normalized
|
||||
|
||||
|
||||
def _resolve_commander_csv(path: str | Path | None) -> Path:
|
||||
if path:
|
||||
return Path(path).resolve()
|
||||
return Path(DEFAULT_COMMANDER_CSV).resolve()
|
||||
|
||||
|
||||
def _resolve_deck_dir(path: str | Path | None) -> Path:
|
||||
if path:
|
||||
return Path(path).resolve()
|
||||
return Path(DEFAULT_DECK_DIR).resolve()
|
||||
|
||||
|
||||
def _resolve_output(path: str | Path | None) -> Path:
|
||||
if path:
|
||||
return Path(path).resolve()
|
||||
return Path(DEFAULT_OUTPUT_PATH).resolve()
|
||||
|
||||
|
||||
def _load_commander_catalog(commander_csv: Path) -> pd.DataFrame:
|
||||
if not commander_csv.exists():
|
||||
raise FileNotFoundError(f"Commander catalog not found: {commander_csv}")
|
||||
converters = getattr(_bc, "COMMANDER_CONVERTERS", None)
|
||||
if converters:
|
||||
df = pd.read_csv(commander_csv, converters=converters)
|
||||
else: # pragma: no cover - legacy path
|
||||
df = pd.read_csv(commander_csv)
|
||||
if "themeTags" not in df.columns:
|
||||
df["themeTags"] = [[] for _ in range(len(df))]
|
||||
if "roleTags" not in df.columns:
|
||||
df["roleTags"] = [[] for _ in range(len(df))]
|
||||
return df
|
||||
|
||||
|
||||
def _build_commander_index(df: pd.DataFrame) -> Tuple[Dict[str, CommanderRecord], Dict[str, dict]]:
|
||||
index: Dict[str, CommanderRecord] = {}
|
||||
theme_map: Dict[str, dict] = {}
|
||||
for _, row in df.iterrows():
|
||||
name = str(row.get("name", "")).strip()
|
||||
display_name = str(row.get("faceName", "")).strip() or name
|
||||
if not display_name:
|
||||
continue
|
||||
key = _normalize_name(display_name)
|
||||
if key in index:
|
||||
continue # Prefer first occurrence for deterministic output.
|
||||
|
||||
color_identity = _normalize_color_identity(_coerce_sequence(row.get("colorIdentity")))
|
||||
if not color_identity:
|
||||
color_identity = _normalize_color_identity(_coerce_sequence(row.get("colors")))
|
||||
theme_tags = tuple(sorted({tag.strip() for tag in _coerce_sequence(row.get("themeTags")) if tag.strip()}, key=str.casefold))
|
||||
role_tags = tuple(sorted({tag.strip() for tag in _coerce_sequence(row.get("roleTags")) if tag.strip()}, key=str.casefold))
|
||||
|
||||
partner_with_col = _coerce_sequence(
|
||||
row.get("partnerWith")
|
||||
or row.get("partner_with")
|
||||
or row.get("partnerNames")
|
||||
or row.get("partner_names")
|
||||
)
|
||||
|
||||
detection = analyze_partner_background(
|
||||
row.get("type") or row.get("type_line"),
|
||||
row.get("text") or row.get("oracleText"),
|
||||
theme_tags or role_tags,
|
||||
)
|
||||
|
||||
supports_backgrounds = bool(
|
||||
_normalize_bool(row.get("supportsBackgrounds") or row.get("supports_backgrounds"))
|
||||
or detection.choose_background
|
||||
)
|
||||
is_partner_flag = bool(_normalize_bool(row.get("isPartner") or row.get("is_partner")) or detection.has_partner)
|
||||
is_background_flag = bool(_normalize_bool(row.get("isBackground") or row.get("is_background")) or detection.is_background)
|
||||
is_doctor_flag = bool(_normalize_bool(row.get("isDoctor") or row.get("is_doctor")) or detection.is_doctor)
|
||||
is_companion_flag = bool(
|
||||
_normalize_bool(row.get("isDoctorsCompanion") or row.get("is_doctors_companion"))
|
||||
or detection.is_doctors_companion
|
||||
)
|
||||
|
||||
partner_metadata = PartnerMetadata(
|
||||
has_partner=is_partner_flag,
|
||||
partner_with=tuple(sorted(set(partner_with_col) | set(detection.partner_with), key=str.casefold)),
|
||||
supports_backgrounds=supports_backgrounds,
|
||||
choose_background=detection.choose_background,
|
||||
is_background=is_background_flag,
|
||||
is_doctor=is_doctor_flag,
|
||||
is_doctors_companion=is_companion_flag,
|
||||
has_plain_partner=detection.has_plain_partner,
|
||||
has_restricted_partner=detection.has_restricted_partner,
|
||||
restricted_partner_labels=tuple(sorted(detection.restricted_partner_labels, key=str.casefold)),
|
||||
)
|
||||
|
||||
record = CommanderRecord(
|
||||
key=key,
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
color_identity=color_identity,
|
||||
themes=theme_tags,
|
||||
role_tags=role_tags,
|
||||
partner_metadata=partner_metadata,
|
||||
)
|
||||
index[key] = record
|
||||
|
||||
for tag in theme_tags:
|
||||
display, canon = _normalize_tag(tag)
|
||||
if not canon:
|
||||
continue
|
||||
entry = theme_map.setdefault(canon, {"name": display, "commanders": set(), "co_occurrence": {}, "deck_count": 0})
|
||||
if not entry["name"]:
|
||||
entry["name"] = display
|
||||
entry["commanders"].add(display_name)
|
||||
return index, theme_map
|
||||
|
||||
|
||||
def _deck_id_from_path(path: Path) -> str:
|
||||
name = path.name
|
||||
if name.endswith(".summary.json"):
|
||||
return name[:-len(".summary.json")]
|
||||
stem = path.stem
|
||||
return stem
|
||||
|
||||
|
||||
def _collect_deck_records(deck_dir: Path) -> Dict[str, DeckRecord]:
|
||||
records: Dict[str, DeckRecord] = {}
|
||||
if not deck_dir.exists():
|
||||
return records
|
||||
|
||||
summary_paths = sorted(deck_dir.glob("*.summary.json"))
|
||||
for path in summary_paths:
|
||||
deck_id = _deck_id_from_path(path)
|
||||
record = records.setdefault(deck_id, DeckRecord(deck_id=deck_id))
|
||||
record.sources.add(str(path.name))
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
meta = payload.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
commander_name = meta.get("commander")
|
||||
if commander_name and not record.commanders:
|
||||
record.commanders = [str(commander_name).strip()]
|
||||
tags = meta.get("tags")
|
||||
if isinstance(tags, list):
|
||||
record.add_tags(tags)
|
||||
summary = payload.get("summary")
|
||||
if isinstance(summary, dict):
|
||||
commander_block = summary.get("commander")
|
||||
if isinstance(commander_block, dict):
|
||||
names = commander_block.get("names")
|
||||
if isinstance(names, list) and names:
|
||||
record.commanders = [str(name).strip() for name in names if str(name).strip()]
|
||||
primary = commander_block.get("primary")
|
||||
secondary = commander_block.get("secondary")
|
||||
if primary and not record.commanders:
|
||||
record.commanders = [str(primary).strip()]
|
||||
if secondary:
|
||||
record.commanders.append(str(secondary).strip())
|
||||
record.set_mode(commander_block.get("partner_mode"))
|
||||
text_paths = sorted(deck_dir.glob("*.txt"))
|
||||
for path in text_paths:
|
||||
deck_id = _deck_id_from_path(path)
|
||||
record = records.setdefault(deck_id, DeckRecord(deck_id=deck_id))
|
||||
record.sources.add(str(path.name))
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
lines = [next(handle).rstrip("\n") for _ in range(10)]
|
||||
except StopIteration:
|
||||
lines = []
|
||||
except Exception:
|
||||
lines = []
|
||||
commanders_line = next((line for line in lines if line.startswith("# Commanders:")), None)
|
||||
if commanders_line:
|
||||
commanders_txt = commanders_line.split(":", 1)[1].strip()
|
||||
commanders = [part.strip() for part in commanders_txt.split(",") if part.strip()]
|
||||
if commanders:
|
||||
record.commanders = commanders
|
||||
else:
|
||||
single_line = next((line for line in lines if line.startswith("# Commander:")), None)
|
||||
if single_line and not record.commanders:
|
||||
commander_txt = single_line.split(":", 1)[1].strip()
|
||||
if commander_txt:
|
||||
record.commanders = [commander_txt]
|
||||
mode_line = next((line for line in lines if line.startswith("# Partner Mode:")), None)
|
||||
if mode_line:
|
||||
mode_txt = mode_line.split(":", 1)[1].strip()
|
||||
record.set_mode(mode_txt)
|
||||
background_line = next((line for line in lines if line.startswith("# Background:")), None)
|
||||
if background_line:
|
||||
background_txt = background_line.split(":", 1)[1].strip()
|
||||
if background_txt:
|
||||
if record.commanders and len(record.commanders) == 1:
|
||||
record.commanders.append(background_txt)
|
||||
elif background_txt not in record.commanders:
|
||||
record.commanders.append(background_txt)
|
||||
record.set_mode("background")
|
||||
return records
|
||||
|
||||
|
||||
def _infer_missing_modes(records: Dict[str, DeckRecord], commander_index: Dict[str, CommanderRecord]) -> None:
|
||||
for record in records.values():
|
||||
if len(record.commanders) <= 1:
|
||||
continue
|
||||
if record.partner_mode not in {"partner", "partner_with", "background", "doctor_companion"}:
|
||||
primary_key = _normalize_name(record.commanders[0])
|
||||
secondary_key = _normalize_name(record.commanders[1])
|
||||
primary = commander_index.get(primary_key)
|
||||
secondary = commander_index.get(secondary_key)
|
||||
if primary and secondary:
|
||||
if secondary.partner_metadata.is_background:
|
||||
record.partner_mode = "background"
|
||||
elif primary.partner_metadata.partner_with and secondary.display_name in primary.partner_metadata.partner_with:
|
||||
record.partner_mode = "partner_with"
|
||||
elif primary.partner_metadata.is_doctor and secondary.partner_metadata.is_doctors_companion:
|
||||
record.partner_mode = "doctor_companion"
|
||||
elif primary.partner_metadata.is_doctors_companion and secondary.partner_metadata.is_doctor:
|
||||
record.partner_mode = "doctor_companion"
|
||||
elif primary.partner_metadata.has_partner and secondary.partner_metadata.has_partner:
|
||||
record.partner_mode = "partner"
|
||||
else:
|
||||
record.partner_mode = "unknown"
|
||||
else:
|
||||
record.partner_mode = "unknown"
|
||||
|
||||
|
||||
def _update_commander_usage(records: Dict[str, DeckRecord], commander_index: Dict[str, CommanderRecord]) -> None:
|
||||
for record in records.values():
|
||||
if not record.commanders:
|
||||
continue
|
||||
for idx, name in enumerate(record.commanders):
|
||||
key = _normalize_name(name)
|
||||
entry = commander_index.get(key)
|
||||
if entry is None:
|
||||
continue
|
||||
if idx == 0:
|
||||
entry.usage_primary += 1
|
||||
else:
|
||||
entry.usage_secondary += 1
|
||||
|
||||
|
||||
def _build_theme_statistics(
|
||||
records: Dict[str, DeckRecord],
|
||||
theme_map: Dict[str, dict],
|
||||
) -> None:
|
||||
for record in records.values():
|
||||
if not record.tags:
|
||||
continue
|
||||
tags = list(record.tags.items())
|
||||
for canonical, display in tags:
|
||||
entry = theme_map.setdefault(canonical, {"name": display, "commanders": set(), "co_occurrence": {}, "deck_count": 0})
|
||||
if not entry["name"]:
|
||||
entry["name"] = display
|
||||
entry["deck_count"] += 1
|
||||
for i in range(len(tags)):
|
||||
canon_a, display_a = tags[i]
|
||||
for j in range(i + 1, len(tags)):
|
||||
canon_b, display_b = tags[j]
|
||||
if canon_a == canon_b:
|
||||
continue
|
||||
entry_a = theme_map.setdefault(canon_a, {"name": display_a, "commanders": set(), "co_occurrence": {}, "deck_count": 0})
|
||||
entry_b = theme_map.setdefault(canon_b, {"name": display_b, "commanders": set(), "co_occurrence": {}, "deck_count": 0})
|
||||
co_a = entry_a.setdefault("co_occurrence", {})
|
||||
co_b = entry_b.setdefault("co_occurrence", {})
|
||||
co_a[canon_b] = co_a.get(canon_b, 0) + 1
|
||||
co_b[canon_a] = co_b.get(canon_a, 0) + 1
|
||||
|
||||
|
||||
def _collect_pairing_stats(
|
||||
records: Dict[str, DeckRecord],
|
||||
commander_index: Dict[str, CommanderRecord],
|
||||
max_examples: int,
|
||||
) -> Tuple[List[dict], Dict[str, int]]:
|
||||
stats: Dict[Tuple[str, str, str], PairingStat] = {}
|
||||
mode_counts: Dict[str, int] = defaultdict(int)
|
||||
for record in records.values():
|
||||
if len(record.commanders) <= 1:
|
||||
continue
|
||||
primary_name = record.commanders[0]
|
||||
secondary_name = record.commanders[1]
|
||||
primary_key = _normalize_name(primary_name)
|
||||
secondary_key = _normalize_name(secondary_name)
|
||||
mode = record.partner_mode or "unknown"
|
||||
mode_counts[mode] += 1
|
||||
stat = stats.get((mode, primary_key, secondary_key))
|
||||
if stat is None:
|
||||
stat = PairingStat(
|
||||
mode=mode,
|
||||
primary_key=primary_key,
|
||||
primary_name=commander_index.get(primary_key, CommanderRecord(
|
||||
key=primary_key,
|
||||
name=primary_name,
|
||||
display_name=primary_name,
|
||||
color_identity=tuple(),
|
||||
themes=tuple(),
|
||||
role_tags=tuple(),
|
||||
partner_metadata=PartnerMetadata(
|
||||
has_partner=False,
|
||||
partner_with=tuple(),
|
||||
supports_backgrounds=False,
|
||||
choose_background=False,
|
||||
is_background=False,
|
||||
is_doctor=False,
|
||||
is_doctors_companion=False,
|
||||
has_plain_partner=False,
|
||||
has_restricted_partner=False,
|
||||
restricted_partner_labels=tuple(),
|
||||
),
|
||||
)).display_name,
|
||||
secondary_key=secondary_key,
|
||||
secondary_name=commander_index.get(secondary_key, CommanderRecord(
|
||||
key=secondary_key,
|
||||
name=secondary_name,
|
||||
display_name=secondary_name,
|
||||
color_identity=tuple(),
|
||||
themes=tuple(),
|
||||
role_tags=tuple(),
|
||||
partner_metadata=PartnerMetadata(
|
||||
has_partner=False,
|
||||
partner_with=tuple(),
|
||||
supports_backgrounds=False,
|
||||
choose_background=False,
|
||||
is_background=False,
|
||||
is_doctor=False,
|
||||
is_doctors_companion=False,
|
||||
has_plain_partner=False,
|
||||
has_restricted_partner=False,
|
||||
restricted_partner_labels=tuple(),
|
||||
),
|
||||
)).display_name,
|
||||
)
|
||||
stats[(mode, primary_key, secondary_key)] = stat
|
||||
stat.add(record.deck_id, record.tags.values(), max_examples)
|
||||
records_list = [stat.to_dict(commander_index) for stat in stats.values()]
|
||||
records_list.sort(key=lambda entry: (-entry["count"], entry["mode"], entry["primary"], entry.get("secondary", "")))
|
||||
return records_list, dict(sorted(mode_counts.items(), key=lambda item: item[0]))
|
||||
|
||||
|
||||
def build_partner_suggestions(
|
||||
*,
|
||||
commander_csv: str | Path | None = None,
|
||||
deck_dir: str | Path | None = None,
|
||||
output_path: str | Path | None = None,
|
||||
max_examples: int = 5,
|
||||
) -> dict[str, object]:
|
||||
"""Generate the partner suggestion support dataset."""
|
||||
|
||||
commander_csv_path = _resolve_commander_csv(commander_csv)
|
||||
deck_directory = _resolve_deck_dir(deck_dir)
|
||||
output_file = _resolve_output(output_path)
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
commander_df = _load_commander_catalog(commander_csv_path)
|
||||
commander_index, theme_map = _build_commander_index(commander_df)
|
||||
deck_records = _collect_deck_records(deck_directory)
|
||||
_infer_missing_modes(deck_records, commander_index)
|
||||
_update_commander_usage(deck_records, commander_index)
|
||||
_build_theme_statistics(deck_records, theme_map)
|
||||
pairing_records, mode_counts = _collect_pairing_stats(deck_records, commander_index, max_examples)
|
||||
|
||||
commanders_payload = {
|
||||
key: record.to_dict() for key, record in sorted(commander_index.items(), key=lambda item: item[0])
|
||||
}
|
||||
|
||||
themes_payload: Dict[str, dict] = {}
|
||||
for canonical, entry in sorted(theme_map.items(), key=lambda item: item[0]):
|
||||
commanders = sorted(entry.get("commanders", []), key=str.casefold)
|
||||
co_map = entry.get("co_occurrence", {}) or {}
|
||||
co_payload = {
|
||||
other: {
|
||||
"name": theme_map.get(other, {"name": other}).get("name", other),
|
||||
"count": count,
|
||||
}
|
||||
for other, count in sorted(co_map.items(), key=lambda item: item[0])
|
||||
}
|
||||
themes_payload[canonical] = {
|
||||
"name": entry.get("name", canonical),
|
||||
"commanders": commanders,
|
||||
"commander_count": len(commanders),
|
||||
"deck_count": entry.get("deck_count", 0),
|
||||
"co_occurrence": co_payload,
|
||||
}
|
||||
|
||||
generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
decks_processed = sum(1 for record in deck_records.values() if record.commanders)
|
||||
decks_with_pairs = sum(1 for record in deck_records.values() if len(record.commanders) >= 2)
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"metadata": {
|
||||
"generated_at": generated_at,
|
||||
"commander_csv": str(commander_csv_path),
|
||||
"deck_directory": str(deck_directory),
|
||||
"output_path": str(output_file),
|
||||
"commander_count": len(commander_index),
|
||||
"theme_count": len(themes_payload),
|
||||
"deck_exports_total": len(deck_records),
|
||||
"deck_exports_processed": decks_processed,
|
||||
"deck_exports_with_pairs": decks_with_pairs,
|
||||
},
|
||||
"commanders": commanders_payload,
|
||||
"themes": themes_payload,
|
||||
"pairings": {
|
||||
"records": pairing_records,
|
||||
"mode_counts": mode_counts,
|
||||
},
|
||||
}
|
||||
|
||||
hash_input = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
version_hash = hashlib.sha256(hash_input).hexdigest()
|
||||
payload["metadata"]["version_hash"] = version_hash
|
||||
payload["curated_overrides"] = {"version": version_hash, "entries": {}}
|
||||
|
||||
with output_file.open("w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Build partner suggestion support dataset")
|
||||
parser.add_argument("--commander-csv", dest="commander_csv", default=None, help="Path to commander_cards.csv")
|
||||
parser.add_argument("--deck-dir", dest="deck_dir", default=None, help="Directory containing deck export files")
|
||||
parser.add_argument("--output", dest="output_path", default=None, help="Output JSON path")
|
||||
parser.add_argument("--max-examples", dest="max_examples", type=int, default=5, help="Maximum example deck IDs to retain per pairing")
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
payload = build_partner_suggestions(
|
||||
commander_csv=args.commander_csv,
|
||||
deck_dir=args.deck_dir,
|
||||
output_path=args.output_path,
|
||||
max_examples=args.max_examples,
|
||||
)
|
||||
|
||||
summary = payload.get("metadata", {})
|
||||
decks = summary.get("deck_exports_processed", 0)
|
||||
pairs = len(payload.get("pairings", {}).get("records", []))
|
||||
print(
|
||||
f"partner_suggestions dataset written to {summary.get('output_path')} "
|
||||
f"(commanders={summary.get('commander_count')}, decks={decks}, pairings={pairs})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
159
code/scripts/generate_background_cards.py
Normal file
159
code/scripts/generate_background_cards.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Generate `background_cards.csv` from the master card dataset.
|
||||
|
||||
This script filters the full `cards.csv` export for cards whose type line contains
|
||||
"Background" and writes the filtered rows to `background_cards.csv`. The output
|
||||
maintains the same columns as the source data, ensures deterministic ordering,
|
||||
and prepends a metadata comment with version and row count.
|
||||
|
||||
Usage (default paths derived from CSV_FILES_DIR environment variable)::
|
||||
|
||||
python -m code.scripts.generate_background_cards
|
||||
python -m code.scripts.generate_background_cards --source other/cards.csv --output some/backgrounds.csv
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Sequence
|
||||
|
||||
from path_util import csv_dir
|
||||
|
||||
BACKGROUND_KEYWORD = "background"
|
||||
DEFAULT_SOURCE_NAME = "cards.csv"
|
||||
DEFAULT_OUTPUT_NAME = "background_cards.csv"
|
||||
|
||||
|
||||
def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Generate background cards CSV")
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=Path,
|
||||
help="Optional override for the source cards.csv file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
help="Optional override for the generated background_cards.csv file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
type=str,
|
||||
help="Optional version string to embed in the output metadata comment",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _resolve_paths(args: argparse.Namespace) -> tuple[Path, Path]:
|
||||
base = Path(csv_dir()).resolve()
|
||||
source = (args.source or (base / DEFAULT_SOURCE_NAME)).resolve()
|
||||
output = (args.output or (base / DEFAULT_OUTPUT_NAME)).resolve()
|
||||
return source, output
|
||||
|
||||
|
||||
def _is_background_type(type_line: str | None) -> bool:
|
||||
if not type_line:
|
||||
return False
|
||||
return BACKGROUND_KEYWORD in type_line.lower()
|
||||
|
||||
|
||||
def _parse_theme_tags(raw: str | None) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return []
|
||||
if text.startswith("[") and text.endswith("]"):
|
||||
body = text[1:-1].strip()
|
||||
if not body:
|
||||
return []
|
||||
tokens = [token.strip(" '\"") for token in body.split(",")]
|
||||
return [token for token in tokens if token]
|
||||
return [part.strip() for part in text.split(";") if part.strip()]
|
||||
|
||||
|
||||
def _is_background_row(row: Dict[str, str]) -> bool:
|
||||
if _is_background_type(row.get("type")):
|
||||
return True
|
||||
theme_tags = _parse_theme_tags(row.get("themeTags"))
|
||||
return any(BACKGROUND_KEYWORD in tag.lower() for tag in theme_tags)
|
||||
|
||||
|
||||
def _row_priority(row: Dict[str, str]) -> tuple[int, int]:
|
||||
"""Return priority tuple for duplicate selection.
|
||||
|
||||
Prefer rows that explicitly declare a background type line, then those with
|
||||
longer oracle text. Higher tuple values take precedence when comparing
|
||||
candidates.
|
||||
"""
|
||||
|
||||
type_line = row.get("type", "") or ""
|
||||
has_type = BACKGROUND_KEYWORD in type_line.lower()
|
||||
text_length = len((row.get("text") or "").strip())
|
||||
return (1 if has_type else 0, text_length)
|
||||
|
||||
|
||||
def _gather_background_rows(reader: csv.DictReader) -> list[Dict[str, str]]:
|
||||
selected: Dict[str, Dict[str, str]] = {}
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
name = (row.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
if not _is_background_row(row):
|
||||
continue
|
||||
current = selected.get(name.lower())
|
||||
if current is None:
|
||||
selected[name.lower()] = row
|
||||
continue
|
||||
if _row_priority(row) > _row_priority(current):
|
||||
selected[name.lower()] = row
|
||||
ordered_names = sorted(selected.keys())
|
||||
return [selected[key] for key in ordered_names]
|
||||
|
||||
|
||||
def _ensure_all_columns(rows: Iterable[Dict[str, str]], headers: List[str]) -> None:
|
||||
for row in rows:
|
||||
for header in headers:
|
||||
row.setdefault(header, "")
|
||||
|
||||
|
||||
def _write_background_csv(output: Path, headers: List[str], rows: List[Dict[str, str]], version: str, source: Path) -> None:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
now_utc = _dt.datetime.now(_dt.UTC).replace(microsecond=0)
|
||||
metadata = {
|
||||
"version": version,
|
||||
"count": str(len(rows)),
|
||||
"source": source.name,
|
||||
"generated": now_utc.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
meta_line = "# " + " ".join(f"{key}={value}" for key, value in metadata.items())
|
||||
with output.open("w", encoding="utf-8", newline="") as handle:
|
||||
handle.write(meta_line + "\n")
|
||||
writer = csv.DictWriter(handle, fieldnames=headers)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow({key: row.get(key, "") for key in headers})
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> None:
|
||||
args = _parse_args(argv)
|
||||
source, output = _resolve_paths(args)
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f"Source cards CSV not found: {source}")
|
||||
|
||||
with source.open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
if reader.fieldnames is None:
|
||||
raise ValueError("cards.csv is missing header row")
|
||||
rows = _gather_background_rows(reader)
|
||||
_ensure_all_columns(rows, list(reader.fieldnames))
|
||||
|
||||
version = args.version or _dt.datetime.now(_dt.UTC).strftime("%Y%m%d")
|
||||
_write_background_csv(output, list(reader.fieldnames), rows, version, source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -249,6 +249,10 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
|
|||
print('\n====================\n')
|
||||
tag_for_keywords(df, color)
|
||||
print('\n====================\n')
|
||||
|
||||
## Tag for partner effects
|
||||
tag_for_partner_effects(df, color)
|
||||
print('\n====================\n')
|
||||
|
||||
## Tag for various effects
|
||||
tag_for_cost_reduction(df, color)
|
||||
|
|
@ -591,10 +595,27 @@ def tag_for_keywords(df: pd.DataFrame, color: str) -> None:
|
|||
if has_keywords.any():
|
||||
# Vectorized split and merge into themeTags
|
||||
keywords_df = df.loc[has_keywords, ['themeTags', 'keywords']].copy()
|
||||
df.loc[has_keywords, 'themeTags'] = keywords_df.apply(
|
||||
lambda r: sorted(list(set((r['themeTags'] if isinstance(r['themeTags'], list) else []) + (r['keywords'].split(', ') if isinstance(r['keywords'], str) else [])))),
|
||||
axis=1
|
||||
)
|
||||
exclusion_keywords = {'partner'}
|
||||
|
||||
def _merge_keywords(row: pd.Series) -> list[str]:
|
||||
base_tags = row['themeTags'] if isinstance(row['themeTags'], list) else []
|
||||
keywords_raw = row['keywords']
|
||||
|
||||
if isinstance(keywords_raw, str):
|
||||
keywords_iterable = [part.strip() for part in keywords_raw.split(',')]
|
||||
elif isinstance(keywords_raw, (list, tuple, set)):
|
||||
keywords_iterable = [str(part).strip() for part in keywords_raw]
|
||||
else:
|
||||
keywords_iterable = []
|
||||
|
||||
filtered_keywords = [
|
||||
kw for kw in keywords_iterable
|
||||
if kw and kw.lower() not in exclusion_keywords
|
||||
]
|
||||
|
||||
return sorted(list(set(base_tags + filtered_keywords)))
|
||||
|
||||
df.loc[has_keywords, 'themeTags'] = keywords_df.apply(_merge_keywords, axis=1)
|
||||
|
||||
duration = (pd.Timestamp.now() - start_time).total_seconds()
|
||||
logger.info('Tagged %d cards with keywords in %.2f seconds', has_keywords.sum(), duration)
|
||||
|
|
@ -616,6 +637,56 @@ def sort_theme_tags(df, color):
|
|||
logger.info(f'Theme tags alphabetically sorted in {color}_cards.csv.')
|
||||
return df.reindex(columns=available)
|
||||
|
||||
### Partner Mechanics
|
||||
def tag_for_partner_effects(df: pd.DataFrame, color: str) -> None:
|
||||
"""Tag cards for partner-related keywords.
|
||||
|
||||
Looks for 'partner', 'partner with', and permutations in rules text and
|
||||
applies tags accordingly.
|
||||
"""
|
||||
logger.info(f'Tagging Partner keywords in {color}_cards.csv')
|
||||
start_time = pd.Timestamp.now()
|
||||
|
||||
try:
|
||||
rules = []
|
||||
partner_mask = tag_utils.create_text_mask(df, r"\bpartner\b(?!\s*(?:with|[-—–]))")
|
||||
if partner_mask.any():
|
||||
rules.append({ 'mask': partner_mask, 'tags': ['Partner'] })
|
||||
|
||||
partner_with_mask = tag_utils.create_text_mask(df, 'partner with')
|
||||
if partner_with_mask.any():
|
||||
rules.append({ 'mask': partner_with_mask, 'tags': ['Partner with'] })
|
||||
|
||||
partner_survivors_mask = tag_utils.create_text_mask(df, r"Partner\s*[-—–]\s*Survivors")
|
||||
if partner_survivors_mask.any():
|
||||
rules.append({ 'mask': partner_survivors_mask, 'tags': ['Partner - Survivors'] })
|
||||
|
||||
partner_father_and_son = tag_utils.create_text_mask(df, r"Partner\s*[-—–]\s*Father\s*&\s*Son")
|
||||
if partner_father_and_son.any():
|
||||
rules.append({ 'mask': partner_father_and_son, 'tags': ['Partner - Father & Son'] })
|
||||
|
||||
friends_forever_mask = tag_utils.create_text_mask(df, 'Friends forever')
|
||||
if friends_forever_mask.any():
|
||||
rules.append({ 'mask': friends_forever_mask, 'tags': ['Friends Forever'] })
|
||||
|
||||
doctors_companion_mask = tag_utils.create_text_mask(df, "Doctor's companion")
|
||||
if doctors_companion_mask.any():
|
||||
rules.append({ 'mask': doctors_companion_mask, 'tags': ["Doctor's Companion"] })
|
||||
|
||||
if rules:
|
||||
tag_utils.apply_rules(df, rules)
|
||||
total = sum(int(r['mask'].sum()) for r in rules)
|
||||
logger.info('Tagged %d cards with Partner keywords', total)
|
||||
else:
|
||||
logger.info('No Partner keywords found')
|
||||
|
||||
duration = (pd.Timestamp.now() - start_time).total_seconds()
|
||||
logger.info('Completed Bending tagging in %.2fs', duration)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error tagging Bending keywords: {str(e)}')
|
||||
raise
|
||||
|
||||
### Cost reductions
|
||||
def tag_for_cost_reduction(df: pd.DataFrame, color: str) -> None:
|
||||
"""Tag cards that reduce spell costs using vectorized operations.
|
||||
|
|
@ -4677,7 +4748,11 @@ def tag_for_bending(df: pd.DataFrame, color: str) -> None:
|
|||
|
||||
earth_mask = tag_utils.create_text_mask(df, 'earthbend')
|
||||
if earth_mask.any():
|
||||
rules.append({ 'mask': earth_mask, 'tags': ['Earthbend', 'Lands Matter', 'Landfall'] })
|
||||
rules.append({ 'mask': earth_mask, 'tags': ['Earthbending', 'Lands Matter', 'Landfall'] })
|
||||
|
||||
bending_mask = air_mask | water_mask | fire_mask | earth_mask
|
||||
if bending_mask.any():
|
||||
rules.append({ 'mask': bending_mask, 'tags': ['Bending'] })
|
||||
|
||||
if rules:
|
||||
tag_utils.apply_rules(df, rules)
|
||||
|
|
@ -5827,7 +5902,7 @@ def tag_for_planeswalkers(df: pd.DataFrame, color: str) -> None:
|
|||
tag_utils.apply_rules(df, rules=[
|
||||
{
|
||||
'mask': final_mask,
|
||||
'tags': ['Planeswalkers', 'Super Friends']
|
||||
'tags': ['Planeswalkers', 'Superfriends']
|
||||
}
|
||||
])
|
||||
|
||||
|
|
|
|||
62
code/tests/test_background_loader.py
Normal file
62
code/tests/test_background_loader.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from code.deck_builder.background_loader import (
|
||||
BackgroundCatalog,
|
||||
BackgroundCard,
|
||||
clear_background_cards_cache,
|
||||
load_background_cards,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache() -> None:
|
||||
clear_background_cards_cache()
|
||||
|
||||
|
||||
def _write_csv(tmp_path: Path, rows: str) -> Path:
|
||||
path = tmp_path / "background_cards.csv"
|
||||
path.write_text(rows, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_load_background_cards_filters_non_backgrounds(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
caplog.set_level("INFO")
|
||||
csv_text = """# version=123 count=2\nname,faceName,type,text,themeTags,colorIdentity,colors,manaCost,manaValue,keywords,edhrecRank,layout,side\nAcolyte of Bahamut,,Legendary Enchantment — Background,Commander creatures you own have menace.,['Backgrounds Matter'],G,G,{1}{G},2.0,,7570,normal,\nNot a Background,,Legendary Creature — Elf,Partner with Foo,,G,G,{3}{G},4.0,,5000,normal,\n"""
|
||||
path = _write_csv(tmp_path, csv_text)
|
||||
catalog = load_background_cards(path)
|
||||
|
||||
assert isinstance(catalog, BackgroundCatalog)
|
||||
assert [card.display_name for card in catalog.entries] == ["Acolyte of Bahamut"]
|
||||
assert catalog.version == "123"
|
||||
assert "background_cards_loaded" in caplog.text
|
||||
|
||||
|
||||
def test_load_background_cards_empty_file(tmp_path: Path) -> None:
|
||||
csv_text = """# version=empty count=0\nname,faceName,type,text,themeTags,colorIdentity,colors,manaCost,manaValue,keywords,edhrecRank,layout,side\n"""
|
||||
path = _write_csv(tmp_path, csv_text)
|
||||
catalog = load_background_cards(path)
|
||||
|
||||
assert catalog.version == "empty"
|
||||
assert catalog.entries == tuple()
|
||||
|
||||
|
||||
def test_load_background_cards_deduplicates_by_name(tmp_path: Path) -> None:
|
||||
csv_text = (
|
||||
"# version=dedupe count=2\n"
|
||||
"name,faceName,type,text,themeTags,colorIdentity,colors,manaCost,manaValue,keywords,edhrecRank,layout,side\n"
|
||||
"Guild Artisan,,Legendary Enchantment — Background,Commander creatures you own have treasure.,['Backgrounds Matter'],R,R,{1}{R},2.0,,3366,normal,\n"
|
||||
"Guild Artisan,,Legendary Enchantment — Background,Commander creatures you own have treasure tokens.,['Backgrounds Matter'],R,R,{1}{R},2.0,,3366,normal,\n"
|
||||
)
|
||||
path = _write_csv(tmp_path, csv_text)
|
||||
catalog = load_background_cards(path)
|
||||
|
||||
assert len(catalog.entries) == 1
|
||||
card = catalog.entries[0]
|
||||
assert isinstance(card, BackgroundCard)
|
||||
assert card.display_name == "Guild Artisan"
|
||||
assert "treasure" in card.oracle_text.lower()
|
||||
assert catalog.get("guild artisan") is card
|
||||
147
code/tests/test_cli_partner_config.py
Normal file
147
code/tests/test_cli_partner_config.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
hr = importlib.import_module("code.headless_runner")
|
||||
|
||||
|
||||
def _parse_cli(args: list[str]) -> object:
|
||||
parser = hr._build_arg_parser()
|
||||
return parser.parse_args(args)
|
||||
|
||||
|
||||
def test_cli_partner_options_in_dry_run(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DECK_SECONDARY_COMMANDER", raising=False)
|
||||
monkeypatch.delenv("ENABLE_PARTNER_MECHANICS", raising=False)
|
||||
args = _parse_cli(
|
||||
[
|
||||
"--commander",
|
||||
"Halana, Kessig Ranger",
|
||||
"--secondary-commander",
|
||||
"Alena, Kessig Trapper",
|
||||
"--enable-partner-mechanics",
|
||||
"true",
|
||||
"--dry-run",
|
||||
]
|
||||
)
|
||||
json_cfg: dict[str, object] = {}
|
||||
secondary = hr._resolve_string_option(args.secondary_commander, "DECK_SECONDARY_COMMANDER", json_cfg, "secondary_commander")
|
||||
background = hr._resolve_string_option(args.background, "DECK_BACKGROUND", json_cfg, "background")
|
||||
partner_flag = hr._resolve_bool_option(args.enable_partner_mechanics, "ENABLE_PARTNER_MECHANICS", json_cfg, "enable_partner_mechanics")
|
||||
assert secondary == "Alena, Kessig Trapper"
|
||||
assert background is None
|
||||
assert partner_flag is True
|
||||
|
||||
|
||||
def test_cli_background_option_in_dry_run(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DECK_BACKGROUND", raising=False)
|
||||
monkeypatch.delenv("ENABLE_PARTNER_MECHANICS", raising=False)
|
||||
args = _parse_cli(
|
||||
[
|
||||
"--commander",
|
||||
"Lae'zel, Vlaakith's Champion",
|
||||
"--background",
|
||||
"Scion of Halaster",
|
||||
"--enable-partner-mechanics",
|
||||
"true",
|
||||
"--dry-run",
|
||||
]
|
||||
)
|
||||
json_cfg: dict[str, object] = {}
|
||||
background = hr._resolve_string_option(args.background, "DECK_BACKGROUND", json_cfg, "background")
|
||||
partner_flag = hr._resolve_bool_option(args.enable_partner_mechanics, "ENABLE_PARTNER_MECHANICS", json_cfg, "enable_partner_mechanics")
|
||||
assert background == "Scion of Halaster"
|
||||
assert partner_flag is True
|
||||
|
||||
|
||||
def test_env_flag_enables_partner_mechanics(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ENABLE_PARTNER_MECHANICS", "1")
|
||||
args = _parse_cli(
|
||||
[
|
||||
"--commander",
|
||||
"Halana, Kessig Ranger",
|
||||
"--secondary-commander",
|
||||
"Alena, Kessig Trapper",
|
||||
"--dry-run",
|
||||
]
|
||||
)
|
||||
json_cfg: dict[str, object] = {}
|
||||
partner_flag = hr._resolve_bool_option(args.enable_partner_mechanics, "ENABLE_PARTNER_MECHANICS", json_cfg, "enable_partner_mechanics")
|
||||
assert partner_flag is True
|
||||
|
||||
|
||||
def _extract_json_payload(stdout: str) -> dict[str, object]:
|
||||
start = stdout.find("{")
|
||||
end = stdout.rfind("}")
|
||||
if start == -1 or end == -1 or end < start:
|
||||
raise AssertionError(f"Expected JSON object in output, received: {stdout!r}")
|
||||
snippet = stdout[start : end + 1]
|
||||
return json.loads(snippet)
|
||||
|
||||
|
||||
def test_json_config_secondary_commander_parsing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
cfg_dir = tmp_path / "cfg"
|
||||
cfg_dir.mkdir()
|
||||
config_path = cfg_dir / "deck.json"
|
||||
config_payload = {
|
||||
"commander": "Halana, Kessig Ranger",
|
||||
"secondary_commander": "Alena, Kessig Trapper",
|
||||
"enable_partner_mechanics": True,
|
||||
}
|
||||
config_path.write_text(json.dumps(config_payload), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(hr, "_ensure_data_ready", lambda: None)
|
||||
monkeypatch.delenv("DECK_SECONDARY_COMMANDER", raising=False)
|
||||
monkeypatch.delenv("ENABLE_PARTNER_MECHANICS", raising=False)
|
||||
monkeypatch.delenv("DECK_BACKGROUND", raising=False)
|
||||
monkeypatch.setattr(sys, "argv", ["headless_runner.py", "--config", str(config_path), "--dry-run"])
|
||||
|
||||
exit_code = hr._main()
|
||||
assert exit_code == 0
|
||||
|
||||
captured = capsys.readouterr()
|
||||
payload = _extract_json_payload(captured.out.strip())
|
||||
assert payload["secondary_commander"] == "Alena, Kessig Trapper"
|
||||
assert payload["background"] is None
|
||||
assert payload["enable_partner_mechanics"] is True
|
||||
|
||||
|
||||
def test_json_config_background_parsing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
cfg_dir = tmp_path / "cfg"
|
||||
cfg_dir.mkdir(exist_ok=True)
|
||||
config_path = cfg_dir / "deck.json"
|
||||
config_payload = {
|
||||
"commander": "Lae'zel, Vlaakith's Champion",
|
||||
"background": "Scion of Halaster",
|
||||
"enable_partner_mechanics": True,
|
||||
}
|
||||
config_path.write_text(json.dumps(config_payload), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(hr, "_ensure_data_ready", lambda: None)
|
||||
monkeypatch.delenv("DECK_SECONDARY_COMMANDER", raising=False)
|
||||
monkeypatch.delenv("ENABLE_PARTNER_MECHANICS", raising=False)
|
||||
monkeypatch.delenv("DECK_BACKGROUND", raising=False)
|
||||
monkeypatch.setattr(sys, "argv", ["headless_runner.py", "--config", str(config_path), "--dry-run"])
|
||||
|
||||
exit_code = hr._main()
|
||||
assert exit_code == 0
|
||||
|
||||
captured = capsys.readouterr()
|
||||
payload = _extract_json_payload(captured.out.strip())
|
||||
assert payload["background"] == "Scion of Halaster"
|
||||
assert payload["secondary_commander"] is None
|
||||
assert payload["enable_partner_mechanics"] is True
|
||||
254
code/tests/test_combined_commander.py
Normal file
254
code/tests/test_combined_commander.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from code.deck_builder.combined_commander import (
|
||||
CombinedCommander,
|
||||
PartnerMode,
|
||||
build_combined_commander,
|
||||
)
|
||||
from exceptions import CommanderPartnerError
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeCommander:
|
||||
name: str
|
||||
display_name: str
|
||||
color_identity: tuple[str, ...]
|
||||
themes: tuple[str, ...] = ()
|
||||
partner_with: tuple[str, ...] = ()
|
||||
is_partner: bool = False
|
||||
supports_backgrounds: bool = False
|
||||
is_background: bool = False
|
||||
oracle_text: str = ""
|
||||
type_line: str = "Legendary Creature"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeBackground:
|
||||
name: str
|
||||
display_name: str
|
||||
color_identity: tuple[str, ...]
|
||||
theme_tags: tuple[str, ...] = ()
|
||||
is_background: bool = True
|
||||
oracle_text: str = "Commander creatures you own have menace."
|
||||
type_line: str = "Legendary Enchantment — Background"
|
||||
|
||||
|
||||
def test_build_combined_commander_none_mode() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Primary",
|
||||
display_name="Primary",
|
||||
color_identity=("R", "G"),
|
||||
themes=("Aggro", "Tokens"),
|
||||
)
|
||||
|
||||
combined = build_combined_commander(primary, None, PartnerMode.NONE)
|
||||
|
||||
assert isinstance(combined, CombinedCommander)
|
||||
assert combined.secondary_name is None
|
||||
assert combined.color_identity == ("R", "G")
|
||||
assert combined.theme_tags == ("Aggro", "Tokens")
|
||||
assert combined.warnings == tuple()
|
||||
assert combined.raw_tags_secondary == tuple()
|
||||
|
||||
|
||||
def test_build_combined_commander_partner_mode() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Halana",
|
||||
display_name="Halana",
|
||||
color_identity=("G",),
|
||||
themes=("Aggro",),
|
||||
is_partner=True,
|
||||
)
|
||||
secondary = FakeCommander(
|
||||
name="Alena",
|
||||
display_name="Alena",
|
||||
color_identity=("U",),
|
||||
themes=("Control",),
|
||||
is_partner=True,
|
||||
)
|
||||
|
||||
combined = build_combined_commander(primary, secondary, PartnerMode.PARTNER)
|
||||
|
||||
assert combined.secondary_name == "Alena"
|
||||
assert combined.color_identity == ("U", "G")
|
||||
assert combined.theme_tags == ("Aggro", "Control")
|
||||
assert combined.raw_tags_primary == ("Aggro",)
|
||||
assert combined.raw_tags_secondary == ("Control",)
|
||||
|
||||
|
||||
def test_partner_mode_requires_partner_keyword() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Halana",
|
||||
display_name="Halana",
|
||||
color_identity=("G",),
|
||||
themes=("Aggro",),
|
||||
is_partner=True,
|
||||
)
|
||||
secondary = FakeCommander(
|
||||
name="NonPartner",
|
||||
display_name="NonPartner",
|
||||
color_identity=("U",),
|
||||
themes=("Control",),
|
||||
is_partner=False,
|
||||
)
|
||||
|
||||
with pytest.raises(CommanderPartnerError):
|
||||
build_combined_commander(primary, secondary, PartnerMode.PARTNER)
|
||||
|
||||
|
||||
def test_partner_with_mode_requires_matching_pairs() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Commander A",
|
||||
display_name="Commander A",
|
||||
color_identity=("W",),
|
||||
themes=("Value",),
|
||||
partner_with=("Commander B",),
|
||||
)
|
||||
secondary = FakeCommander(
|
||||
name="Commander B",
|
||||
display_name="Commander B",
|
||||
color_identity=("B",),
|
||||
themes=("Graveyard",),
|
||||
partner_with=("Commander A",),
|
||||
)
|
||||
|
||||
combined = build_combined_commander(primary, secondary, PartnerMode.PARTNER_WITH)
|
||||
|
||||
assert combined.secondary_name == "Commander B"
|
||||
assert combined.color_identity == ("W", "B")
|
||||
assert combined.theme_tags == ("Value", "Graveyard")
|
||||
|
||||
|
||||
def test_partner_with_mode_invalid_pair_raises() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Commander A",
|
||||
display_name="Commander A",
|
||||
color_identity=("W",),
|
||||
partner_with=("Commander X",),
|
||||
)
|
||||
secondary = FakeCommander(
|
||||
name="Commander B",
|
||||
display_name="Commander B",
|
||||
color_identity=("B",),
|
||||
partner_with=("Commander A",),
|
||||
)
|
||||
|
||||
with pytest.raises(CommanderPartnerError):
|
||||
build_combined_commander(primary, secondary, PartnerMode.PARTNER_WITH)
|
||||
|
||||
|
||||
def test_background_mode_success() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Lae'zel",
|
||||
display_name="Lae'zel",
|
||||
color_identity=("W",),
|
||||
themes=("Counters",),
|
||||
supports_backgrounds=True,
|
||||
)
|
||||
background = FakeBackground(
|
||||
name="Scion of Halaster",
|
||||
display_name="Scion of Halaster",
|
||||
color_identity=("B",),
|
||||
theme_tags=("Backgrounds Matter",),
|
||||
)
|
||||
|
||||
combined = build_combined_commander(primary, background, PartnerMode.BACKGROUND)
|
||||
|
||||
assert combined.secondary_name == "Scion of Halaster"
|
||||
assert combined.color_identity == ("W", "B")
|
||||
assert combined.theme_tags == ("Counters", "Backgrounds Matter")
|
||||
|
||||
|
||||
def test_background_mode_requires_support() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Halana",
|
||||
display_name="Halana",
|
||||
color_identity=("G",),
|
||||
themes=("Aggro",),
|
||||
supports_backgrounds=False,
|
||||
)
|
||||
background = FakeBackground(
|
||||
name="Scion of Halaster",
|
||||
display_name="Scion of Halaster",
|
||||
color_identity=("B",),
|
||||
)
|
||||
|
||||
with pytest.raises(CommanderPartnerError):
|
||||
build_combined_commander(primary, background, PartnerMode.BACKGROUND)
|
||||
|
||||
|
||||
def test_duplicate_commander_not_allowed() -> None:
|
||||
primary = FakeCommander(name="A", display_name="Same", color_identity=("G",), is_partner=True)
|
||||
secondary = FakeCommander(name="B", display_name="Same", color_identity=("U",), is_partner=True)
|
||||
|
||||
with pytest.raises(CommanderPartnerError):
|
||||
build_combined_commander(primary, secondary, PartnerMode.PARTNER)
|
||||
|
||||
|
||||
def test_colorless_partner_with_colored_results_in_colored_identity_only() -> None:
|
||||
primary = FakeCommander(name="Ulamog", display_name="Ulamog", color_identity=tuple(), is_partner=True)
|
||||
secondary = FakeCommander(name="Tana", display_name="Tana", color_identity=("G",), is_partner=True)
|
||||
|
||||
combined = build_combined_commander(primary, secondary, PartnerMode.PARTNER)
|
||||
|
||||
assert combined.color_identity == ("G",)
|
||||
|
||||
|
||||
def test_warning_emitted_for_multi_mode_primary() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Wilson",
|
||||
display_name="Wilson",
|
||||
color_identity=("G",),
|
||||
themes=("Aggro",),
|
||||
is_partner=True,
|
||||
supports_backgrounds=True,
|
||||
)
|
||||
|
||||
combined = build_combined_commander(primary, None, PartnerMode.NONE)
|
||||
|
||||
assert combined.warnings == (
|
||||
"Wilson has both Partner and Background abilities; ensure the selected mode is intentional.",
|
||||
)
|
||||
|
||||
|
||||
def test_partner_mode_rejects_background_secondary() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Halana",
|
||||
display_name="Halana",
|
||||
color_identity=("G",),
|
||||
themes=("Aggro",),
|
||||
is_partner=True,
|
||||
)
|
||||
background = FakeBackground(
|
||||
name="Scion of Halaster",
|
||||
display_name="Scion of Halaster",
|
||||
color_identity=("B",),
|
||||
)
|
||||
|
||||
with pytest.raises(CommanderPartnerError):
|
||||
build_combined_commander(primary, background, PartnerMode.PARTNER)
|
||||
|
||||
|
||||
def test_theme_tags_deduplicate_preserving_order() -> None:
|
||||
primary = FakeCommander(
|
||||
name="Commander A",
|
||||
display_name="Commander A",
|
||||
color_identity=("W",),
|
||||
themes=("Value", "Control"),
|
||||
is_partner=True,
|
||||
)
|
||||
secondary = FakeCommander(
|
||||
name="Commander B",
|
||||
display_name="Commander B",
|
||||
color_identity=("U",),
|
||||
themes=("Control", "Tempo"),
|
||||
is_partner=True,
|
||||
)
|
||||
|
||||
combined = build_combined_commander(primary, secondary, PartnerMode.PARTNER)
|
||||
|
||||
assert combined.theme_tags == ("Value", "Control", "Tempo")
|
||||
|
|
@ -5,10 +5,11 @@ from pathlib import Path
|
|||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import commander_exclusions
|
||||
import headless_runner as hr
|
||||
from exceptions import CommanderValidationError
|
||||
from file_setup import setup_utils as su
|
||||
from file_setup.setup_utils import filter_dataframe, process_legendary_cards
|
||||
from file_setup.setup_utils import process_legendary_cards
|
||||
import settings
|
||||
|
||||
|
||||
|
|
@ -118,16 +119,62 @@ def test_primary_face_retained_and_log_cleared(tmp_csv_dir):
|
|||
assert len(processed) == 1
|
||||
assert processed.iloc[0]["faceName"] == "Birgi, God of Storytelling"
|
||||
|
||||
# Downstream filter should continue to succeed with a single primary row
|
||||
filtered = filter_dataframe(processed, [])
|
||||
assert len(filtered) == 1
|
||||
|
||||
exclusion_path = tmp_csv_dir / ".commander_exclusions.json"
|
||||
assert not exclusion_path.exists(), "No exclusion log expected when primary face remains"
|
||||
def test_determine_commanders_generates_background_catalog(tmp_csv_dir, monkeypatch):
|
||||
import importlib
|
||||
|
||||
setup_module = importlib.import_module("file_setup.setup")
|
||||
monkeypatch.setattr(setup_module, "filter_dataframe", lambda df, banned: df)
|
||||
|
||||
commander_row = _make_card_row(
|
||||
name="Hero of the Realm",
|
||||
face_name="Hero of the Realm",
|
||||
type_line="Legendary Creature — Human Knight",
|
||||
side=None,
|
||||
layout="normal",
|
||||
power="3",
|
||||
toughness="3",
|
||||
text="Vigilance",
|
||||
)
|
||||
|
||||
background_row = _make_card_row(
|
||||
name="Mentor of Courage",
|
||||
face_name="Mentor of Courage",
|
||||
type_line="Legendary Enchantment — Background",
|
||||
side=None,
|
||||
layout="normal",
|
||||
text="Commander creatures you own have vigilance.",
|
||||
)
|
||||
|
||||
cards_df = pd.DataFrame([commander_row, background_row])
|
||||
cards_df.to_csv(tmp_csv_dir / "cards.csv", index=False)
|
||||
|
||||
color_df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"name": "Hero of the Realm",
|
||||
"faceName": "Hero of the Realm",
|
||||
"themeTags": "['Valor']",
|
||||
"creatureTypes": "['Human', 'Knight']",
|
||||
"roleTags": "['Commander']",
|
||||
}
|
||||
]
|
||||
)
|
||||
color_df.to_csv(tmp_csv_dir / "white_cards.csv", index=False)
|
||||
|
||||
setup_module.determine_commanders()
|
||||
|
||||
background_path = tmp_csv_dir / "background_cards.csv"
|
||||
assert background_path.exists(), "Expected background catalog to be generated"
|
||||
|
||||
lines = background_path.read_text(encoding="utf-8").splitlines()
|
||||
assert lines, "Background catalog should not be empty"
|
||||
assert lines[0].startswith("# ")
|
||||
assert any("Mentor of Courage" in line for line in lines[1:])
|
||||
|
||||
|
||||
def test_headless_validation_reports_secondary_face(monkeypatch):
|
||||
monkeypatch.setattr(hr, "_load_commander_name_lookup", lambda: set())
|
||||
monkeypatch.setattr(hr, "_load_commander_name_lookup", lambda: (set(), tuple()))
|
||||
|
||||
exclusion_entry = {
|
||||
"name": "Elbrus, the Binding Blade // Withengar Unbound",
|
||||
|
|
@ -135,7 +182,11 @@ def test_headless_validation_reports_secondary_face(monkeypatch):
|
|||
"eligible_faces": ["Withengar Unbound"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(hr, "lookup_commander_detail", lambda name: exclusion_entry if "Withengar" in name else None)
|
||||
monkeypatch.setattr(
|
||||
commander_exclusions,
|
||||
"lookup_commander_detail",
|
||||
lambda name: exclusion_entry if "Withengar" in name else None,
|
||||
)
|
||||
|
||||
with pytest.raises(CommanderValidationError) as excinfo:
|
||||
hr._validate_commander_available("Withengar Unbound")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import types
|
|||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from code.deck_builder.summary_telemetry import _reset_metrics_for_test, record_partner_summary
|
||||
|
||||
fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installed
|
||||
|
||||
|
||||
|
|
@ -121,3 +123,51 @@ def test_commanders_nav_visible_by_default():
|
|||
assert r.status_code == 200
|
||||
body = r.text
|
||||
assert '<a href="/commanders"' in body
|
||||
|
||||
|
||||
def test_partner_metrics_endpoint_reports_color_sources():
|
||||
app_module = load_app_with_env(SHOW_DIAGNOSTICS="1")
|
||||
_reset_metrics_for_test()
|
||||
record_partner_summary(
|
||||
{
|
||||
"primary": "Tana, the Bloodsower",
|
||||
"secondary": "Nadir Kraken",
|
||||
"names": ["Tana, the Bloodsower", "Nadir Kraken"],
|
||||
"partner_mode": "partner",
|
||||
"combined": {
|
||||
"partner_mode": "partner",
|
||||
"color_identity": ["G", "U"],
|
||||
"color_code": "GU",
|
||||
"color_label": "Simic (GU)",
|
||||
"color_sources": [
|
||||
{"color": "G", "providers": [{"name": "Tana, the Bloodsower", "role": "primary"}]},
|
||||
{"color": "U", "providers": [{"name": "Nadir Kraken", "role": "partner"}]},
|
||||
],
|
||||
"color_delta": {
|
||||
"added": ["U"],
|
||||
"removed": [],
|
||||
"primary": ["G"],
|
||||
"secondary": ["U"],
|
||||
},
|
||||
"secondary_role": "partner",
|
||||
"secondary_role_label": "Partner commander",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
client = TestClient(app_module.app)
|
||||
resp = client.get("/status/partner_metrics")
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert payload.get("ok") is True
|
||||
metrics = payload.get("metrics") or {}
|
||||
assert metrics.get("total_pairs", 0) >= 1
|
||||
last = metrics.get("last_summary")
|
||||
assert last is not None
|
||||
sources = last.get("color_sources") or []
|
||||
assert any(entry.get("color") == "G" for entry in sources)
|
||||
assert any(
|
||||
provider.get("role") == "partner"
|
||||
for entry in sources
|
||||
for provider in entry.get("providers", [])
|
||||
)
|
||||
|
|
|
|||
110
code/tests/test_export_commander_metadata.py
Normal file
110
code/tests/test_export_commander_metadata.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from code.deck_builder.combined_commander import CombinedCommander, PartnerMode
|
||||
from code.deck_builder.phases.phase6_reporting import ReportingMixin
|
||||
|
||||
|
||||
class MetadataBuilder(ReportingMixin):
|
||||
def __init__(self) -> None:
|
||||
self.card_library = {
|
||||
"Halana, Kessig Ranger": {
|
||||
"Card Type": "Legendary Creature",
|
||||
"Count": 1,
|
||||
"Mana Cost": "{3}{G}",
|
||||
"Mana Value": "4",
|
||||
"Role": "Commander",
|
||||
"Tags": ["Partner"],
|
||||
},
|
||||
"Alena, Kessig Trapper": {
|
||||
"Card Type": "Legendary Creature",
|
||||
"Count": 1,
|
||||
"Mana Cost": "{4}{R}",
|
||||
"Mana Value": "5",
|
||||
"Role": "Commander",
|
||||
"Tags": ["Partner"],
|
||||
},
|
||||
"Gruul Signet": {
|
||||
"Card Type": "Artifact",
|
||||
"Count": 1,
|
||||
"Mana Cost": "{2}",
|
||||
"Mana Value": "2",
|
||||
"Role": "Ramp",
|
||||
"Tags": [],
|
||||
},
|
||||
}
|
||||
self.output_func = lambda *_args, **_kwargs: None
|
||||
self.combined_commander = CombinedCommander(
|
||||
primary_name="Halana, Kessig Ranger",
|
||||
secondary_name="Alena, Kessig Trapper",
|
||||
partner_mode=PartnerMode.PARTNER,
|
||||
color_identity=("G", "R"),
|
||||
theme_tags=("counters", "aggro"),
|
||||
raw_tags_primary=("counters",),
|
||||
raw_tags_secondary=("aggro",),
|
||||
warnings=(),
|
||||
)
|
||||
self.commander_name = "Halana, Kessig Ranger"
|
||||
self.secondary_commander = "Alena, Kessig Trapper"
|
||||
self.partner_mode = PartnerMode.PARTNER
|
||||
self.combined_color_identity = ("G", "R")
|
||||
self.color_identity = ["G", "R"]
|
||||
self.selected_tags = ["Counters", "Aggro"]
|
||||
self.primary_tag = "Counters"
|
||||
self.secondary_tag = "Aggro"
|
||||
self.tertiary_tag = None
|
||||
self.custom_export_base = "metadata_builder"
|
||||
|
||||
|
||||
def _suppress_color_matrix(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = types.ModuleType("deck_builder.builder_utils")
|
||||
stub.compute_color_source_matrix = lambda *_args, **_kwargs: {}
|
||||
stub.multi_face_land_info = lambda *_args, **_kwargs: {}
|
||||
monkeypatch.setitem(sys.modules, "deck_builder.builder_utils", stub)
|
||||
|
||||
|
||||
def test_csv_header_includes_commander_names(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_suppress_color_matrix(monkeypatch)
|
||||
builder = MetadataBuilder()
|
||||
csv_path = Path(builder.export_decklist_csv(directory=str(tmp_path), filename="deck.csv"))
|
||||
with csv_path.open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
assert reader.fieldnames is not None
|
||||
assert reader.fieldnames[-1] == "Commanders: Halana, Kessig Ranger, Alena, Kessig Trapper"
|
||||
rows = list(reader)
|
||||
assert any(row["Name"] == "Gruul Signet" for row in rows)
|
||||
|
||||
|
||||
def test_text_export_includes_commander_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_suppress_color_matrix(monkeypatch)
|
||||
builder = MetadataBuilder()
|
||||
text_path = Path(builder.export_decklist_text(directory=str(tmp_path), filename="deck.txt"))
|
||||
lines = text_path.read_text(encoding="utf-8").splitlines()
|
||||
assert lines[0] == "# Commanders: Halana, Kessig Ranger, Alena, Kessig Trapper"
|
||||
assert lines[1] == "# Partner Mode: partner"
|
||||
assert lines[2] == "# Colors: G, R"
|
||||
assert lines[4].startswith("1 Halana, Kessig Ranger")
|
||||
|
||||
|
||||
def test_summary_contains_combined_commander_block(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_suppress_color_matrix(monkeypatch)
|
||||
builder = MetadataBuilder()
|
||||
summary = builder.build_deck_summary()
|
||||
commander_block = summary["commander"]
|
||||
assert commander_block["names"] == [
|
||||
"Halana, Kessig Ranger",
|
||||
"Alena, Kessig Trapper",
|
||||
]
|
||||
assert commander_block["partner_mode"] == "partner"
|
||||
assert commander_block["color_identity"] == ["G", "R"]
|
||||
combined = commander_block["combined"]
|
||||
assert combined["primary_name"] == "Halana, Kessig Ranger"
|
||||
assert combined["secondary_name"] == "Alena, Kessig Trapper"
|
||||
assert combined["partner_mode"] == "partner"
|
||||
assert combined["color_identity"] == ["G", "R"]
|
||||
|
|
@ -47,7 +47,10 @@ class TestJSONRoundTrip:
|
|||
"exclude_cards": ["Chaos Orb", "Shahrazad", "Time Walk"],
|
||||
"enforcement_mode": "strict",
|
||||
"allow_illegal": True,
|
||||
"fuzzy_matching": False
|
||||
"fuzzy_matching": False,
|
||||
"secondary_commander": "Alena, Kessig Trapper",
|
||||
"background": None,
|
||||
"enable_partner_mechanics": True,
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
|
|
@ -65,6 +68,9 @@ class TestJSONRoundTrip:
|
|||
assert loaded_config["enforcement_mode"] == "strict"
|
||||
assert loaded_config["allow_illegal"] is True
|
||||
assert loaded_config["fuzzy_matching"] is False
|
||||
assert loaded_config["secondary_commander"] == "Alena, Kessig Trapper"
|
||||
assert loaded_config["background"] is None
|
||||
assert loaded_config["enable_partner_mechanics"] is True
|
||||
|
||||
# Create a DeckBuilder with this config and export again
|
||||
builder = DeckBuilder()
|
||||
|
|
@ -75,6 +81,10 @@ class TestJSONRoundTrip:
|
|||
builder.allow_illegal = loaded_config["allow_illegal"]
|
||||
builder.fuzzy_matching = loaded_config["fuzzy_matching"]
|
||||
builder.bracket_level = loaded_config["bracket_level"]
|
||||
builder.partner_feature_enabled = loaded_config["enable_partner_mechanics"]
|
||||
builder.partner_mode = "partner"
|
||||
builder.secondary_commander = loaded_config["secondary_commander"]
|
||||
builder.requested_secondary_commander = loaded_config["secondary_commander"]
|
||||
|
||||
# Export the configuration
|
||||
exported_path = builder.export_run_config_json(directory=temp_dir, suppress_output=True)
|
||||
|
|
@ -94,6 +104,9 @@ class TestJSONRoundTrip:
|
|||
assert re_exported_config["theme_catalog_version"] is None
|
||||
assert re_exported_config["userThemes"] == []
|
||||
assert re_exported_config["themeCatalogVersion"] is None
|
||||
assert re_exported_config["secondary_commander"] == "Alena, Kessig Trapper"
|
||||
assert re_exported_config["background"] is None
|
||||
assert re_exported_config["enable_partner_mechanics"] is True
|
||||
|
||||
def test_empty_lists_round_trip(self):
|
||||
"""Test that empty include/exclude lists are handled correctly."""
|
||||
|
|
@ -121,6 +134,9 @@ class TestJSONRoundTrip:
|
|||
assert exported_config["fuzzy_matching"] is True
|
||||
assert exported_config["userThemes"] == []
|
||||
assert exported_config["themeCatalogVersion"] is None
|
||||
assert exported_config["secondary_commander"] is None
|
||||
assert exported_config["background"] is None
|
||||
assert exported_config["enable_partner_mechanics"] is False
|
||||
|
||||
def test_default_values_export(self):
|
||||
"""Test that default values are exported correctly."""
|
||||
|
|
@ -145,6 +161,9 @@ class TestJSONRoundTrip:
|
|||
assert exported_config["additional_themes"] == []
|
||||
assert exported_config["theme_match_mode"] == "permissive"
|
||||
assert exported_config["theme_catalog_version"] is None
|
||||
assert exported_config["secondary_commander"] is None
|
||||
assert exported_config["background"] is None
|
||||
assert exported_config["enable_partner_mechanics"] is False
|
||||
|
||||
def test_backward_compatibility_no_include_exclude_fields(self):
|
||||
"""Test that configs without include/exclude fields still work."""
|
||||
|
|
@ -236,6 +255,24 @@ class TestJSONRoundTrip:
|
|||
sanitized_hash = hashlib.sha256(json.dumps(sanitized_payload, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
assert sanitized_hash == legacy_hash
|
||||
|
||||
def test_export_background_fields(self):
|
||||
builder = DeckBuilder()
|
||||
builder.commander_name = "Test Commander"
|
||||
builder.partner_feature_enabled = True
|
||||
builder.partner_mode = "background"
|
||||
builder.secondary_commander = "Scion of Halaster"
|
||||
builder.requested_background = "Scion of Halaster"
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exported_path = builder.export_run_config_json(directory=temp_dir, suppress_output=True)
|
||||
|
||||
with open(exported_path, 'r', encoding='utf-8') as f:
|
||||
exported_config = json.load(f)
|
||||
|
||||
assert exported_config["enable_partner_mechanics"] is True
|
||||
assert exported_config["background"] == "Scion of Halaster"
|
||||
assert exported_config["secondary_commander"] is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
|
|
|||
36
code/tests/test_orchestrator_partner_helpers.py
Normal file
36
code/tests/test_orchestrator_partner_helpers.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deck_builder.builder import DeckBuilder
|
||||
from code.web.services.orchestrator import _add_secondary_commander_card
|
||||
|
||||
|
||||
def test_add_secondary_commander_card_injects_partner() -> None:
|
||||
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||
partner_name = "Pir, Imaginative Rascal"
|
||||
combined = SimpleNamespace(secondary_name=partner_name)
|
||||
commander_df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"name": partner_name,
|
||||
"type": "Legendary Creature — Human",
|
||||
"manaCost": "{2}{G}",
|
||||
"manaValue": 3,
|
||||
"creatureTypes": ["Human", "Ranger"],
|
||||
"themeTags": ["+1/+1 Counters"],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert partner_name not in builder.card_library
|
||||
|
||||
_add_secondary_commander_card(builder, commander_df, combined)
|
||||
|
||||
assert partner_name in builder.card_library
|
||||
entry = builder.card_library[partner_name]
|
||||
assert entry["Commander"] is True
|
||||
assert entry["Role"] == "commander"
|
||||
assert entry["SubRole"] == "Partner"
|
||||
162
code/tests/test_partner_background_utils.py
Normal file
162
code/tests/test_partner_background_utils.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from code.deck_builder.partner_background_utils import (
|
||||
PartnerBackgroundInfo,
|
||||
analyze_partner_background,
|
||||
extract_partner_with_names,
|
||||
)
|
||||
|
||||
|
||||
def test_extract_partner_with_names_handles_multiple() -> None:
|
||||
text = "Partner with Foo, Bar and Baz (Each half of the pair may be your commander.)"
|
||||
assert extract_partner_with_names(text) == ("Foo", "Bar", "Baz")
|
||||
|
||||
|
||||
def test_extract_partner_with_names_deduplicates() -> None:
|
||||
text = "Partner with Foo, Foo, Bar. Partner with Baz"
|
||||
assert extract_partner_with_names(text) == ("Foo", "Bar", "Baz")
|
||||
|
||||
|
||||
def test_analyze_partner_background_detects_keywords() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — Ally",
|
||||
oracle_text="Partner (You can have two commanders if both have partner.)",
|
||||
theme_tags=("Legends Matter",),
|
||||
)
|
||||
assert info == PartnerBackgroundInfo(
|
||||
has_partner=True,
|
||||
partner_with=tuple(),
|
||||
choose_background=False,
|
||||
is_background=False,
|
||||
is_doctor=False,
|
||||
is_doctors_companion=False,
|
||||
has_plain_partner=True,
|
||||
has_restricted_partner=False,
|
||||
restricted_partner_labels=tuple(),
|
||||
)
|
||||
|
||||
|
||||
def test_analyze_partner_background_detects_choose_background_via_theme() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature",
|
||||
oracle_text="",
|
||||
theme_tags=("Choose a Background",),
|
||||
)
|
||||
assert info.choose_background is True
|
||||
|
||||
|
||||
def test_choose_background_commander_not_marked_as_background() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — Human Warrior",
|
||||
oracle_text=(
|
||||
"Choose a Background (You can have a Background as a second commander.)"
|
||||
),
|
||||
theme_tags=("Backgrounds Matter", "Choose a Background"),
|
||||
)
|
||||
assert info.choose_background is True
|
||||
assert info.is_background is False
|
||||
|
||||
|
||||
def test_analyze_partner_background_detects_background_from_type() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Enchantment — Background",
|
||||
oracle_text="Commander creatures you own have menace.",
|
||||
theme_tags=(),
|
||||
)
|
||||
assert info.is_background is True
|
||||
|
||||
|
||||
def test_analyze_partner_background_rejects_false_positive() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — Human",
|
||||
oracle_text="This creature enjoys partnership events.",
|
||||
theme_tags=("Legends Matter",),
|
||||
)
|
||||
assert info.has_partner is False
|
||||
assert info.has_plain_partner is False
|
||||
assert info.has_restricted_partner is False
|
||||
|
||||
|
||||
def test_analyze_partner_background_detects_partner_with_as_restricted() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — Human",
|
||||
oracle_text="Partner with Foo (They go on adventures together.)",
|
||||
theme_tags=(),
|
||||
)
|
||||
assert info.has_partner is True
|
||||
assert info.has_plain_partner is False
|
||||
assert info.has_restricted_partner is True
|
||||
|
||||
|
||||
def test_analyze_partner_background_requires_time_lord_for_doctor() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — Time Lord Doctor",
|
||||
oracle_text="When you cast a spell, do the thing.",
|
||||
theme_tags=(),
|
||||
)
|
||||
assert info.is_doctor is True
|
||||
|
||||
non_time_lord = analyze_partner_background(
|
||||
type_line="Legendary Creature — Doctor",
|
||||
oracle_text="When you cast a spell, do the other thing.",
|
||||
theme_tags=("Doctor",),
|
||||
)
|
||||
assert non_time_lord.is_doctor is False
|
||||
|
||||
tagged_only = analyze_partner_background(
|
||||
type_line="Legendary Creature — Doctor",
|
||||
oracle_text="When you cast a spell, do the other thing.",
|
||||
theme_tags=("Time Lord Doctor",),
|
||||
)
|
||||
assert tagged_only.is_doctor is False
|
||||
|
||||
|
||||
def test_analyze_partner_background_extracts_dash_restriction_label() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — Survivor",
|
||||
oracle_text="Partner - Survivors (They can only team up with their own.)",
|
||||
theme_tags=(),
|
||||
)
|
||||
assert info.restricted_partner_labels == ("Survivors",)
|
||||
|
||||
|
||||
def test_analyze_partner_background_uses_theme_restriction_label() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — God Warrior",
|
||||
oracle_text="Partner — Father & Son (They go to battle together.)",
|
||||
theme_tags=("Partner - Father & Son",),
|
||||
)
|
||||
assert info.restricted_partner_labels[0].casefold() == "father & son"
|
||||
|
||||
|
||||
def test_analyze_partner_background_detects_restricted_partner_keyword() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — Survivor",
|
||||
oracle_text="Partner — Survivors (They stand together.)",
|
||||
theme_tags=(),
|
||||
)
|
||||
assert info.has_partner is True
|
||||
assert info.has_plain_partner is False
|
||||
assert info.has_restricted_partner is True
|
||||
|
||||
|
||||
def test_analyze_partner_background_detects_ascii_dash_partner_restriction() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — Survivor",
|
||||
oracle_text="Partner - Survivors (They can only team up with their own.)",
|
||||
theme_tags=(),
|
||||
)
|
||||
assert info.has_partner is True
|
||||
assert info.has_plain_partner is False
|
||||
assert info.has_restricted_partner is True
|
||||
|
||||
|
||||
def test_analyze_partner_background_marks_friends_forever_as_restricted() -> None:
|
||||
info = analyze_partner_background(
|
||||
type_line="Legendary Creature — Human",
|
||||
oracle_text="Friends forever (You can have two commanders if both have friends forever.)",
|
||||
theme_tags=(),
|
||||
)
|
||||
assert info.has_partner is True
|
||||
assert info.has_plain_partner is False
|
||||
assert info.has_restricted_partner is True
|
||||
133
code/tests/test_partner_option_filtering.py
Normal file
133
code/tests/test_partner_option_filtering.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from code.web.services.commander_catalog_loader import (
|
||||
CommanderRecord,
|
||||
_row_to_record,
|
||||
shared_restricted_partner_label,
|
||||
)
|
||||
|
||||
|
||||
def _build_row(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"name": "Test Commander",
|
||||
"faceName": "",
|
||||
"side": "",
|
||||
"colorIdentity": "G",
|
||||
"colors": "G",
|
||||
"manaCost": "",
|
||||
"manaValue": "",
|
||||
"type": "Legendary Creature — Human",
|
||||
"creatureTypes": "Human",
|
||||
"text": "",
|
||||
"power": "",
|
||||
"toughness": "",
|
||||
"keywords": "",
|
||||
"themeTags": "[]",
|
||||
"edhrecRank": "",
|
||||
"layout": "normal",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_row_to_record_marks_plain_partner() -> None:
|
||||
row = _build_row(text="Partner (You can have two commanders if both have partner.)")
|
||||
record = _row_to_record(row, used_slugs=set())
|
||||
|
||||
assert isinstance(record, CommanderRecord)
|
||||
assert record.has_plain_partner is True
|
||||
assert record.is_partner is True
|
||||
assert record.partner_with == tuple()
|
||||
|
||||
|
||||
def test_row_to_record_marks_partner_with_as_restricted() -> None:
|
||||
row = _build_row(text="Partner with Foo (You can have two commanders if both have partner.)")
|
||||
record = _row_to_record(row, used_slugs=set())
|
||||
|
||||
assert record.has_plain_partner is False
|
||||
assert record.is_partner is True
|
||||
assert record.partner_with == ("Foo",)
|
||||
|
||||
|
||||
def test_row_to_record_marks_partner_dash_as_restricted() -> None:
|
||||
row = _build_row(text="Partner — Survivors (You can have two commanders if both have partner.)")
|
||||
record = _row_to_record(row, used_slugs=set())
|
||||
|
||||
assert record.has_plain_partner is False
|
||||
assert record.is_partner is True
|
||||
assert record.restricted_partner_labels == ("Survivors",)
|
||||
|
||||
|
||||
def test_row_to_record_marks_ascii_dash_partner_as_restricted() -> None:
|
||||
row = _build_row(text="Partner - Survivors (They have a unique bond.)")
|
||||
record = _row_to_record(row, used_slugs=set())
|
||||
|
||||
assert record.has_plain_partner is False
|
||||
assert record.is_partner is True
|
||||
assert record.restricted_partner_labels == ("Survivors",)
|
||||
|
||||
|
||||
def test_row_to_record_marks_friends_forever_as_restricted() -> None:
|
||||
row = _build_row(text="Friends forever (You can have two commanders if both have friends forever.)")
|
||||
record = _row_to_record(row, used_slugs=set())
|
||||
|
||||
assert record.has_plain_partner is False
|
||||
assert record.is_partner is True
|
||||
|
||||
|
||||
def test_row_to_record_excludes_doctors_companion_from_plain_partner() -> None:
|
||||
row = _build_row(text="Doctor's companion (You can have two commanders if both have a Doctor.)")
|
||||
record = _row_to_record(row, used_slugs=set())
|
||||
|
||||
assert record.has_plain_partner is False
|
||||
assert record.is_partner is False
|
||||
|
||||
|
||||
def test_shared_restricted_partner_label_detects_overlap() -> None:
|
||||
used_slugs: set[str] = set()
|
||||
primary = _row_to_record(
|
||||
_build_row(
|
||||
name="Abby, Merciless Soldier",
|
||||
type="Legendary Creature — Human Survivor",
|
||||
text="Partner - Survivors (They fight as one.)",
|
||||
themeTags="['Partner - Survivors']",
|
||||
),
|
||||
used_slugs=used_slugs,
|
||||
)
|
||||
partner = _row_to_record(
|
||||
_build_row(
|
||||
name="Bruno, Stalwart Survivor",
|
||||
type="Legendary Creature — Human Survivor",
|
||||
text="Partner — Survivors (They rally the clan.)",
|
||||
themeTags="['Partner - Survivors']",
|
||||
),
|
||||
used_slugs=used_slugs,
|
||||
)
|
||||
|
||||
assert shared_restricted_partner_label(primary, partner) == "Survivors"
|
||||
assert shared_restricted_partner_label(primary, primary) == "Survivors"
|
||||
|
||||
|
||||
def test_row_to_record_decodes_literal_newlines() -> None:
|
||||
row = _build_row(text="Partner with Foo\\nFirst strike")
|
||||
record = _row_to_record(row, used_slugs=set())
|
||||
|
||||
assert record.partner_with == ("Foo",)
|
||||
|
||||
|
||||
def test_row_to_record_does_not_mark_companion_as_doctor_when_type_line_lacks_subtype() -> None:
|
||||
row = _build_row(
|
||||
text="Doctor's companion (You can have two commanders if the other is a Doctor.)",
|
||||
creatureTypes="['Doctor', 'Human']",
|
||||
)
|
||||
record = _row_to_record(row, used_slugs=set())
|
||||
|
||||
assert record.is_doctors_companion is True
|
||||
assert record.is_doctor is False
|
||||
|
||||
|
||||
def test_row_to_record_requires_time_lord_for_doctor_flag() -> None:
|
||||
row = _build_row(type="Legendary Creature — Human Doctor")
|
||||
record = _row_to_record(row, used_slugs=set())
|
||||
|
||||
assert record.is_doctor is False
|
||||
293
code/tests/test_partner_scoring.py
Normal file
293
code/tests/test_partner_scoring.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
"""Unit tests for partner suggestion scoring helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from code.deck_builder.combined_commander import PartnerMode
|
||||
from code.deck_builder.suggestions import (
|
||||
PartnerSuggestionContext,
|
||||
score_partner_candidate,
|
||||
)
|
||||
|
||||
|
||||
def _partner_meta(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"has_partner": False,
|
||||
"partner_with": [],
|
||||
"supports_backgrounds": False,
|
||||
"choose_background": False,
|
||||
"is_background": False,
|
||||
"is_doctor": False,
|
||||
"is_doctors_companion": False,
|
||||
"has_plain_partner": False,
|
||||
"has_restricted_partner": False,
|
||||
"restricted_partner_labels": [],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _commander(
|
||||
name: str,
|
||||
*,
|
||||
color_identity: tuple[str, ...] = tuple(),
|
||||
themes: tuple[str, ...] = tuple(),
|
||||
role_tags: tuple[str, ...] = tuple(),
|
||||
partner_meta: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"name": name,
|
||||
"display_name": name,
|
||||
"color_identity": list(color_identity),
|
||||
"themes": list(themes),
|
||||
"role_tags": list(role_tags),
|
||||
"partner": partner_meta or _partner_meta(),
|
||||
"usage": {"primary": 0, "secondary": 0, "total": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_partner_with_prefers_canonical_pairing() -> None:
|
||||
context = PartnerSuggestionContext(
|
||||
theme_cooccurrence={
|
||||
"Counters": {"Ramp": 8, "Flyers": 3},
|
||||
"Ramp": {"Counters": 8},
|
||||
"Flyers": {"Counters": 3},
|
||||
},
|
||||
pairing_counts={
|
||||
("partner_with", "Halana, Kessig Ranger", "Alena, Kessig Trapper"): 12,
|
||||
("partner_with", "Halana, Kessig Ranger", "Ishai, Ojutai Dragonspeaker"): 1,
|
||||
},
|
||||
)
|
||||
|
||||
halana = _commander(
|
||||
"Halana, Kessig Ranger",
|
||||
color_identity=("G",),
|
||||
themes=("Counters", "Removal"),
|
||||
partner_meta=_partner_meta(
|
||||
has_partner=True,
|
||||
partner_with=["Alena, Kessig Trapper"],
|
||||
has_plain_partner=True,
|
||||
),
|
||||
)
|
||||
|
||||
alena = _commander(
|
||||
"Alena, Kessig Trapper",
|
||||
color_identity=("R",),
|
||||
themes=("Ramp", "Counters"),
|
||||
role_tags=("Support",),
|
||||
partner_meta=_partner_meta(
|
||||
has_partner=True,
|
||||
partner_with=["Halana, Kessig Ranger"],
|
||||
has_plain_partner=True,
|
||||
),
|
||||
)
|
||||
|
||||
ishai = _commander(
|
||||
"Ishai, Ojutai Dragonspeaker",
|
||||
color_identity=("W", "U"),
|
||||
themes=("Flyers", "Counters"),
|
||||
partner_meta=_partner_meta(
|
||||
has_partner=True,
|
||||
has_plain_partner=True,
|
||||
),
|
||||
)
|
||||
|
||||
alena_score = score_partner_candidate(
|
||||
halana,
|
||||
alena,
|
||||
mode=PartnerMode.PARTNER_WITH,
|
||||
context=context,
|
||||
)
|
||||
ishai_score = score_partner_candidate(
|
||||
halana,
|
||||
ishai,
|
||||
mode=PartnerMode.PARTNER_WITH,
|
||||
context=context,
|
||||
)
|
||||
|
||||
assert alena_score.score > ishai_score.score
|
||||
assert "partner_with_match" in alena_score.notes
|
||||
assert "missing_partner_with_link" in ishai_score.notes
|
||||
|
||||
|
||||
def test_background_scoring_prioritizes_legal_backgrounds() -> None:
|
||||
context = PartnerSuggestionContext(
|
||||
theme_cooccurrence={
|
||||
"Counters": {"Card Draw": 6, "Aggro": 2},
|
||||
"Card Draw": {"Counters": 6},
|
||||
"Treasure": {"Aggro": 2},
|
||||
},
|
||||
pairing_counts={
|
||||
("background", "Lae'zel, Vlaakith's Champion", "Scion of Halaster"): 9,
|
||||
},
|
||||
)
|
||||
|
||||
laezel = _commander(
|
||||
"Lae'zel, Vlaakith's Champion",
|
||||
color_identity=("W",),
|
||||
themes=("Counters", "Aggro"),
|
||||
partner_meta=_partner_meta(
|
||||
supports_backgrounds=True,
|
||||
),
|
||||
)
|
||||
|
||||
scion = _commander(
|
||||
"Scion of Halaster",
|
||||
color_identity=("B",),
|
||||
themes=("Card Draw", "Dungeons"),
|
||||
partner_meta=_partner_meta(
|
||||
is_background=True,
|
||||
),
|
||||
)
|
||||
|
||||
guild = _commander(
|
||||
"Guild Artisan",
|
||||
color_identity=("R",),
|
||||
themes=("Treasure",),
|
||||
partner_meta=_partner_meta(
|
||||
is_background=True,
|
||||
),
|
||||
)
|
||||
|
||||
not_background = _commander(
|
||||
"Reyhan, Last of the Abzan",
|
||||
color_identity=("B", "G"),
|
||||
themes=("Counters",),
|
||||
partner_meta=_partner_meta(
|
||||
has_partner=True,
|
||||
),
|
||||
)
|
||||
|
||||
scion_score = score_partner_candidate(
|
||||
laezel,
|
||||
scion,
|
||||
mode=PartnerMode.BACKGROUND,
|
||||
context=context,
|
||||
)
|
||||
guild_score = score_partner_candidate(
|
||||
laezel,
|
||||
guild,
|
||||
mode=PartnerMode.BACKGROUND,
|
||||
context=context,
|
||||
)
|
||||
illegal_score = score_partner_candidate(
|
||||
laezel,
|
||||
not_background,
|
||||
mode=PartnerMode.BACKGROUND,
|
||||
context=context,
|
||||
)
|
||||
|
||||
assert scion_score.score > guild_score.score
|
||||
assert guild_score.score > illegal_score.score
|
||||
assert "candidate_not_background" in illegal_score.notes
|
||||
|
||||
|
||||
def test_doctor_companion_scoring_requires_complementary_roles() -> None:
|
||||
context = PartnerSuggestionContext(
|
||||
theme_cooccurrence={
|
||||
"Time Travel": {"Card Draw": 4},
|
||||
"Card Draw": {"Time Travel": 4},
|
||||
},
|
||||
pairing_counts={
|
||||
("doctor_companion", "The Tenth Doctor", "Donna Noble"): 7,
|
||||
},
|
||||
)
|
||||
|
||||
tenth_doctor = _commander(
|
||||
"The Tenth Doctor",
|
||||
color_identity=("U", "R"),
|
||||
themes=("Time Travel", "Card Draw"),
|
||||
partner_meta=_partner_meta(
|
||||
is_doctor=True,
|
||||
),
|
||||
)
|
||||
|
||||
donna = _commander(
|
||||
"Donna Noble",
|
||||
color_identity=("W",),
|
||||
themes=("Card Draw",),
|
||||
partner_meta=_partner_meta(
|
||||
is_doctors_companion=True,
|
||||
),
|
||||
)
|
||||
|
||||
generic = _commander(
|
||||
"Generic Companion",
|
||||
color_identity=("G",),
|
||||
themes=("Aggro",),
|
||||
partner_meta=_partner_meta(
|
||||
has_partner=True,
|
||||
),
|
||||
)
|
||||
|
||||
donna_score = score_partner_candidate(
|
||||
tenth_doctor,
|
||||
donna,
|
||||
mode=PartnerMode.DOCTOR_COMPANION,
|
||||
context=context,
|
||||
)
|
||||
generic_score = score_partner_candidate(
|
||||
tenth_doctor,
|
||||
generic,
|
||||
mode=PartnerMode.DOCTOR_COMPANION,
|
||||
context=context,
|
||||
)
|
||||
|
||||
assert donna_score.score > generic_score.score
|
||||
assert "doctor_companion_match" in donna_score.notes
|
||||
assert "doctor_pairing_illegal" in generic_score.notes
|
||||
|
||||
|
||||
def test_excluded_themes_do_not_inflate_overlap_or_trigger_theme_penalty() -> None:
|
||||
context = PartnerSuggestionContext()
|
||||
|
||||
primary = _commander(
|
||||
"Sisay, Weatherlight Captain",
|
||||
themes=("Legends Matter",),
|
||||
partner_meta=_partner_meta(has_partner=True, has_plain_partner=True),
|
||||
)
|
||||
|
||||
candidate = _commander(
|
||||
"Jodah, the Unifier",
|
||||
themes=("Legends Matter",),
|
||||
partner_meta=_partner_meta(has_partner=True, has_plain_partner=True),
|
||||
)
|
||||
|
||||
result = score_partner_candidate(
|
||||
primary,
|
||||
candidate,
|
||||
mode=PartnerMode.PARTNER,
|
||||
context=context,
|
||||
)
|
||||
|
||||
assert result.components["overlap"] == 0.0
|
||||
assert "missing_theme_metadata" not in result.notes
|
||||
|
||||
|
||||
def test_excluded_themes_removed_from_synergy_calculation() -> None:
|
||||
context = PartnerSuggestionContext(
|
||||
theme_cooccurrence={
|
||||
"Legends Matter": {"Card Draw": 10},
|
||||
"Card Draw": {"Legends Matter": 10},
|
||||
}
|
||||
)
|
||||
|
||||
primary = _commander(
|
||||
"Dihada, Binder of Wills",
|
||||
themes=("Legends Matter",),
|
||||
partner_meta=_partner_meta(has_partner=True, has_plain_partner=True),
|
||||
)
|
||||
|
||||
candidate = _commander(
|
||||
"Tymna the Weaver",
|
||||
themes=("Card Draw",),
|
||||
partner_meta=_partner_meta(has_partner=True, has_plain_partner=True),
|
||||
)
|
||||
|
||||
result = score_partner_candidate(
|
||||
primary,
|
||||
candidate,
|
||||
mode=PartnerMode.PARTNER,
|
||||
context=context,
|
||||
)
|
||||
|
||||
assert result.components["synergy"] == 0.0
|
||||
324
code/tests/test_partner_selection.py
Normal file
324
code/tests/test_partner_selection.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from deck_builder.combined_commander import PartnerMode
|
||||
from deck_builder.partner_selection import apply_partner_inputs
|
||||
from exceptions import CommanderPartnerError
|
||||
|
||||
|
||||
class _StubBuilder:
|
||||
def __init__(self, dataframe: pd.DataFrame) -> None:
|
||||
self._df = dataframe
|
||||
|
||||
def load_commander_data(self) -> pd.DataFrame:
|
||||
return self._df.copy(deep=True)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def builder() -> _StubBuilder:
|
||||
data = [
|
||||
{
|
||||
"name": "Halana, Kessig Ranger",
|
||||
"faceName": "Halana, Kessig Ranger",
|
||||
"colorIdentity": ["G"],
|
||||
"themeTags": ["Aggro"],
|
||||
"text": "Reach\nPartner (You can have two commanders if both have partner.)",
|
||||
"type": "Legendary Creature — Human Archer",
|
||||
},
|
||||
{
|
||||
"name": "Alena, Kessig Trapper",
|
||||
"faceName": "Alena, Kessig Trapper",
|
||||
"colorIdentity": ["R"],
|
||||
"themeTags": ["Aggro"],
|
||||
"text": "First strike\nPartner",
|
||||
"type": "Legendary Creature — Human Scout",
|
||||
},
|
||||
{
|
||||
"name": "Lae'zel, Vlaakith's Champion",
|
||||
"faceName": "Lae'zel, Vlaakith's Champion",
|
||||
"colorIdentity": ["W"],
|
||||
"themeTags": ["Counters"],
|
||||
"text": "If you would put one or more counters on a creature... Choose a Background (You can have a Background as a second commander.)",
|
||||
"type": "Legendary Creature — Gith Warrior",
|
||||
},
|
||||
{
|
||||
"name": "Commander A",
|
||||
"faceName": "Commander A",
|
||||
"colorIdentity": ["W"],
|
||||
"themeTags": ["Value"],
|
||||
"text": "Partner with Commander B (When this creature enters the battlefield, target player may put Commander B into their hand from their library, then shuffle.)",
|
||||
"type": "Legendary Creature — Advisor",
|
||||
},
|
||||
{
|
||||
"name": "Commander B",
|
||||
"faceName": "Commander B",
|
||||
"colorIdentity": ["B"],
|
||||
"themeTags": ["Graveyard"],
|
||||
"text": "Partner with Commander A",
|
||||
"type": "Legendary Creature — Advisor",
|
||||
},
|
||||
{
|
||||
"name": "The Tenth Doctor",
|
||||
"faceName": "The Tenth Doctor",
|
||||
"colorIdentity": ["U", "R"],
|
||||
"themeTags": ["Time", "Doctor"],
|
||||
"text": "Whenever you cast a spell with cascade, put a time counter on target permanent",
|
||||
"type": "Legendary Creature — Time Lord Doctor",
|
||||
},
|
||||
{
|
||||
"name": "Donna Noble",
|
||||
"faceName": "Donna Noble",
|
||||
"colorIdentity": ["W"],
|
||||
"themeTags": ["Support"],
|
||||
"text": "Vigilance\nDoctor's companion (You can have two commanders if the other is a Doctor.)",
|
||||
"type": "Legendary Creature — Human Advisor",
|
||||
},
|
||||
{
|
||||
"name": "Amy Pond",
|
||||
"faceName": "Amy Pond",
|
||||
"colorIdentity": ["R"],
|
||||
"themeTags": ["Aggro", "Doctor's Companion", "Partner With"],
|
||||
"text": (
|
||||
"Partner with Rory Williams\\nWhenever Amy Pond deals combat damage to a player, "
|
||||
"choose a suspended card you own and remove that many time counters from it.\\n"
|
||||
"Doctor's companion (You can have two commanders if the other is the Doctor.)"
|
||||
),
|
||||
"type": "Legendary Creature — Human",
|
||||
},
|
||||
{
|
||||
"name": "Rory Williams",
|
||||
"faceName": "Rory Williams",
|
||||
"colorIdentity": ["W", "U"],
|
||||
"themeTags": ["Human", "Doctor's Companion", "Partner With"],
|
||||
"text": (
|
||||
"Partner with Amy Pond\\nFirst strike, lifelink\\n"
|
||||
"Doctor's companion (You can have two commanders if the other is a Doctor.)"
|
||||
),
|
||||
"type": "Legendary Creature — Human Soldier",
|
||||
},
|
||||
]
|
||||
df = pd.DataFrame(data)
|
||||
return _StubBuilder(df)
|
||||
|
||||
|
||||
def _background_catalog() -> SimpleNamespace:
|
||||
card = SimpleNamespace(
|
||||
name="Scion of Halaster",
|
||||
display_name="Scion of Halaster",
|
||||
color_identity=("B",),
|
||||
themes=("Backgrounds Matter",),
|
||||
theme_tags=("Backgrounds Matter",),
|
||||
oracle_text="Commander creatures you own have menace.",
|
||||
type_line="Legendary Enchantment — Background",
|
||||
is_background=True,
|
||||
)
|
||||
|
||||
class _Catalog:
|
||||
def __init__(self, entry: SimpleNamespace) -> None:
|
||||
self._entry = entry
|
||||
self.entries = (entry,)
|
||||
|
||||
def get(self, name: str) -> SimpleNamespace | None:
|
||||
lowered = name.strip().casefold()
|
||||
if lowered in {
|
||||
self._entry.name.casefold(),
|
||||
self._entry.display_name.casefold(),
|
||||
}:
|
||||
return self._entry
|
||||
return None
|
||||
|
||||
return _Catalog(card)
|
||||
|
||||
|
||||
def test_feature_disabled_returns_none(builder: _StubBuilder) -> None:
|
||||
result = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Halana, Kessig Ranger",
|
||||
secondary_name="Alena, Kessig Trapper",
|
||||
feature_enabled=False,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_conflicting_inputs_raise_error(builder: _StubBuilder) -> None:
|
||||
with pytest.raises(CommanderPartnerError):
|
||||
apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Halana, Kessig Ranger",
|
||||
secondary_name="Alena, Kessig Trapper",
|
||||
background_name="Scion of Halaster",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
|
||||
|
||||
def test_background_requires_primary_support(builder: _StubBuilder) -> None:
|
||||
with pytest.raises(CommanderPartnerError):
|
||||
apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Halana, Kessig Ranger",
|
||||
background_name="Scion of Halaster",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
|
||||
|
||||
def test_background_success(builder: _StubBuilder) -> None:
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Lae'zel, Vlaakith's Champion",
|
||||
background_name="Scion of Halaster",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
assert combined is not None
|
||||
assert combined.partner_mode is PartnerMode.BACKGROUND
|
||||
assert combined.secondary_name == "Scion of Halaster"
|
||||
assert combined.color_identity == ("W", "B")
|
||||
|
||||
|
||||
def test_partner_with_detection(builder: _StubBuilder) -> None:
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Commander A",
|
||||
secondary_name="Commander B",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
assert combined is not None
|
||||
assert combined.partner_mode is PartnerMode.PARTNER_WITH
|
||||
assert combined.color_identity == ("W", "B")
|
||||
|
||||
|
||||
def test_partner_detection(builder: _StubBuilder) -> None:
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Halana, Kessig Ranger",
|
||||
secondary_name="Alena, Kessig Trapper",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
assert combined is not None
|
||||
assert combined.partner_mode is PartnerMode.PARTNER
|
||||
assert combined.color_identity == ("R", "G")
|
||||
|
||||
|
||||
def test_doctor_companion_pairing(builder: _StubBuilder) -> None:
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="The Tenth Doctor",
|
||||
secondary_name="Donna Noble",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
assert combined is not None
|
||||
assert combined.partner_mode is PartnerMode.DOCTOR_COMPANION
|
||||
assert combined.secondary_name == "Donna Noble"
|
||||
assert combined.color_identity == ("W", "U", "R")
|
||||
|
||||
|
||||
def test_doctor_requires_companion(builder: _StubBuilder) -> None:
|
||||
with pytest.raises(CommanderPartnerError):
|
||||
apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="The Tenth Doctor",
|
||||
secondary_name="Halana, Kessig Ranger",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
|
||||
|
||||
def test_companion_requires_doctor(builder: _StubBuilder) -> None:
|
||||
with pytest.raises(CommanderPartnerError):
|
||||
apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Donna Noble",
|
||||
secondary_name="Commander A",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
|
||||
|
||||
def test_amy_prefers_partner_with_when_rory_selected(builder: _StubBuilder) -> None:
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Amy Pond",
|
||||
secondary_name="Rory Williams",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
|
||||
assert combined is not None
|
||||
assert combined.partner_mode is PartnerMode.PARTNER_WITH
|
||||
|
||||
|
||||
def test_amy_can_pair_with_the_doctor(builder: _StubBuilder) -> None:
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Amy Pond",
|
||||
secondary_name="The Tenth Doctor",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
|
||||
assert combined is not None
|
||||
assert combined.partner_mode is PartnerMode.DOCTOR_COMPANION
|
||||
|
||||
|
||||
def test_rory_can_partner_with_amy(builder: _StubBuilder) -> None:
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Rory Williams",
|
||||
secondary_name="Amy Pond",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
|
||||
assert combined is not None
|
||||
assert combined.partner_mode is PartnerMode.PARTNER_WITH
|
||||
|
||||
|
||||
def test_logging_emits_partner_mode_selected(caplog: pytest.LogCaptureFixture, builder: _StubBuilder) -> None:
|
||||
with caplog.at_level(logging.INFO):
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Halana, Kessig Ranger",
|
||||
secondary_name="Alena, Kessig Trapper",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
)
|
||||
|
||||
assert combined is not None
|
||||
records = [record for record in caplog.records if getattr(record, "event", "") == "partner_mode_selected"]
|
||||
assert records, "Expected partner_mode_selected log event"
|
||||
payload = getattr(records[-1], "payload", {})
|
||||
assert payload.get("mode") == PartnerMode.PARTNER.value
|
||||
assert payload.get("commanders", {}).get("primary") == "Halana, Kessig Ranger"
|
||||
assert payload.get("commanders", {}).get("secondary") == "Alena, Kessig Trapper"
|
||||
assert payload.get("colors_before") == ["G"]
|
||||
assert payload.get("colors_after") == ["R", "G"]
|
||||
assert payload.get("color_delta", {}).get("added") == ["R"]
|
||||
|
||||
|
||||
def test_logging_includes_selection_source(caplog: pytest.LogCaptureFixture, builder: _StubBuilder) -> None:
|
||||
with caplog.at_level(logging.INFO):
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name="Halana, Kessig Ranger",
|
||||
secondary_name="Alena, Kessig Trapper",
|
||||
feature_enabled=True,
|
||||
background_catalog=_background_catalog(),
|
||||
selection_source="suggestion",
|
||||
)
|
||||
|
||||
assert combined is not None
|
||||
records = [record for record in caplog.records if getattr(record, "event", "") == "partner_mode_selected"]
|
||||
assert records, "Expected partner_mode_selected log event"
|
||||
payload = getattr(records[-1], "payload", {})
|
||||
assert payload.get("selection_source") == "suggestion"
|
||||
304
code/tests/test_partner_suggestions_api.py
Normal file
304
code/tests/test_partner_suggestions_api.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def _write_dataset(path: Path) -> Path:
|
||||
payload = {
|
||||
"metadata": {
|
||||
"generated_at": "2025-10-06T12:00:00Z",
|
||||
"version": "test-fixture",
|
||||
},
|
||||
"commanders": {
|
||||
"akiri_line_slinger": {
|
||||
"name": "Akiri, Line-Slinger",
|
||||
"display_name": "Akiri, Line-Slinger",
|
||||
"color_identity": ["R", "W"],
|
||||
"themes": ["Artifacts", "Aggro"],
|
||||
"role_tags": ["Aggro"],
|
||||
"partner": {
|
||||
"has_partner": True,
|
||||
"partner_with": ["Silas Renn, Seeker Adept"],
|
||||
"supports_backgrounds": False,
|
||||
},
|
||||
},
|
||||
"silas_renn_seeker_adept": {
|
||||
"name": "Silas Renn, Seeker Adept",
|
||||
"display_name": "Silas Renn, Seeker Adept",
|
||||
"color_identity": ["U", "B"],
|
||||
"themes": ["Artifacts", "Value"],
|
||||
"role_tags": ["Value"],
|
||||
"partner": {
|
||||
"has_partner": True,
|
||||
"partner_with": ["Akiri, Line-Slinger"],
|
||||
"supports_backgrounds": False,
|
||||
},
|
||||
},
|
||||
"ishai_ojutai_dragonspeaker": {
|
||||
"name": "Ishai, Ojutai Dragonspeaker",
|
||||
"display_name": "Ishai, Ojutai Dragonspeaker",
|
||||
"color_identity": ["W", "U"],
|
||||
"themes": ["Artifacts", "Counters"],
|
||||
"role_tags": ["Aggro"],
|
||||
"partner": {
|
||||
"has_partner": True,
|
||||
"partner_with": [],
|
||||
"supports_backgrounds": False,
|
||||
},
|
||||
},
|
||||
"reyhan_last_of_the_abzan": {
|
||||
"name": "Reyhan, Last of the Abzan",
|
||||
"display_name": "Reyhan, Last of the Abzan",
|
||||
"color_identity": ["B", "G"],
|
||||
"themes": ["Counters", "Artifacts"],
|
||||
"role_tags": ["Counters"],
|
||||
"partner": {
|
||||
"has_partner": True,
|
||||
"partner_with": [],
|
||||
"supports_backgrounds": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"pairings": {
|
||||
"records": [
|
||||
{
|
||||
"mode": "partner_with",
|
||||
"primary_canonical": "akiri_line_slinger",
|
||||
"secondary_canonical": "silas_renn_seeker_adept",
|
||||
"count": 12,
|
||||
},
|
||||
{
|
||||
"mode": "partner",
|
||||
"primary_canonical": "akiri_line_slinger",
|
||||
"secondary_canonical": "ishai_ojutai_dragonspeaker",
|
||||
"count": 6,
|
||||
},
|
||||
{
|
||||
"mode": "partner",
|
||||
"primary_canonical": "akiri_line_slinger",
|
||||
"secondary_canonical": "reyhan_last_of_the_abzan",
|
||||
"count": 4,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _fresh_client(tmp_path: Path) -> tuple[TestClient, Path]:
|
||||
dataset_path = _write_dataset(tmp_path / "partner_synergy.json")
|
||||
os.environ["ENABLE_PARTNER_MECHANICS"] = "1"
|
||||
os.environ["ENABLE_PARTNER_SUGGESTIONS"] = "1"
|
||||
for module_name in (
|
||||
"code.web.app",
|
||||
"code.web.routes.partner_suggestions",
|
||||
"code.web.services.partner_suggestions",
|
||||
):
|
||||
sys.modules.pop(module_name, None)
|
||||
from code.web.services import partner_suggestions as partner_service
|
||||
|
||||
partner_service.configure_dataset_path(dataset_path)
|
||||
from code.web.app import app
|
||||
|
||||
client = TestClient(app)
|
||||
return client, dataset_path
|
||||
|
||||
|
||||
async def _receive() -> dict[str, object]:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
|
||||
def _make_request(path: str = "/api/partner/suggestions", query_string: str = "") -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode("utf-8"),
|
||||
"query_string": query_string.encode("utf-8"),
|
||||
"headers": [],
|
||||
"client": ("203.0.113.5", 52345),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
request = Request(scope, receive=_receive) # type: ignore[arg-type]
|
||||
request.state.request_id = "req-telemetry"
|
||||
return request
|
||||
|
||||
|
||||
def test_partner_suggestions_api_returns_ranked_candidates(tmp_path: Path) -> None:
|
||||
client, dataset_path = _fresh_client(tmp_path)
|
||||
try:
|
||||
params = {
|
||||
"commander": "Akiri, Line-Slinger",
|
||||
"visible_limit": 1,
|
||||
"partner": [
|
||||
"Silas Renn, Seeker Adept",
|
||||
"Ishai, Ojutai Dragonspeaker",
|
||||
"Reyhan, Last of the Abzan",
|
||||
],
|
||||
}
|
||||
response = client.get("/api/partner/suggestions", params=params)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["visible"], "expected at least one visible suggestion"
|
||||
assert len(data["visible"]) == 1
|
||||
assert data["hidden"], "expected hidden suggestions when visible_limit=1"
|
||||
assert data["has_hidden"] is True
|
||||
names = [item["name"] for item in data["visible"]]
|
||||
assert names[0] == "Silas Renn, Seeker Adept"
|
||||
assert data["metadata"]["generated_at"] == "2025-10-06T12:00:00Z"
|
||||
|
||||
response_all = client.get(
|
||||
"/api/partner/suggestions",
|
||||
params={**params, "include_hidden": 1},
|
||||
)
|
||||
assert response_all.status_code == 200
|
||||
data_all = response_all.json()
|
||||
assert len(data_all["visible"]) >= data_all["total"] or len(data_all["visible"]) >= 3
|
||||
assert not data_all["hidden"]
|
||||
assert data_all["available_modes"]
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from code.web.services import partner_suggestions as partner_service
|
||||
|
||||
partner_service.configure_dataset_path(None)
|
||||
except Exception:
|
||||
pass
|
||||
os.environ.pop("ENABLE_PARTNER_MECHANICS", None)
|
||||
os.environ.pop("ENABLE_PARTNER_SUGGESTIONS", None)
|
||||
for module_name in (
|
||||
"code.web.app",
|
||||
"code.web.routes.partner_suggestions",
|
||||
"code.web.services.partner_suggestions",
|
||||
):
|
||||
sys.modules.pop(module_name, None)
|
||||
if dataset_path.exists():
|
||||
dataset_path.unlink()
|
||||
|
||||
|
||||
def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkeypatch) -> None:
|
||||
analytics_dir = tmp_path / "config" / "analytics"
|
||||
analytics_dir.mkdir(parents=True)
|
||||
dataset_path = (analytics_dir / "partner_synergy.json").resolve()
|
||||
|
||||
from code.web.services import partner_suggestions as partner_service
|
||||
from code.web.services import orchestrator as orchestrator_service
|
||||
|
||||
original_default = partner_service.DEFAULT_DATASET_PATH
|
||||
original_path = partner_service._DATASET_PATH # type: ignore[attr-defined]
|
||||
original_cache = partner_service._DATASET_CACHE # type: ignore[attr-defined]
|
||||
original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED # type: ignore[attr-defined]
|
||||
|
||||
partner_service.DEFAULT_DATASET_PATH = dataset_path
|
||||
partner_service._DATASET_PATH = dataset_path # type: ignore[attr-defined]
|
||||
partner_service._DATASET_CACHE = None # type: ignore[attr-defined]
|
||||
partner_service._DATASET_REFRESH_ATTEMPTED = True # type: ignore[attr-defined]
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
payload_path = tmp_path / "seed_dataset.json"
|
||||
_write_dataset(payload_path)
|
||||
|
||||
def seeded_refresh(out_func=None, *, force=False, root=None): # type: ignore[override]
|
||||
calls["count"] += 1
|
||||
dataset_path.write_text(payload_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(orchestrator_service, "_maybe_refresh_partner_synergy", seeded_refresh)
|
||||
|
||||
try:
|
||||
result_none = partner_service.load_dataset()
|
||||
assert result_none is None
|
||||
assert calls["count"] == 0
|
||||
|
||||
dataset = partner_service.load_dataset(refresh=True, force=True)
|
||||
assert dataset is not None
|
||||
assert calls["count"] == 1
|
||||
finally:
|
||||
partner_service.DEFAULT_DATASET_PATH = original_default
|
||||
partner_service._DATASET_PATH = original_path # type: ignore[attr-defined]
|
||||
partner_service._DATASET_CACHE = original_cache # type: ignore[attr-defined]
|
||||
partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted # type: ignore[attr-defined]
|
||||
try:
|
||||
dataset_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
payload_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def test_partner_suggestions_api_refresh_flag(monkeypatch) -> None:
|
||||
from code.web.routes import partner_suggestions as route
|
||||
from code.web.services.partner_suggestions import PartnerSuggestionResult
|
||||
|
||||
monkeypatch.setattr(route, "ENABLE_PARTNER_MECHANICS", True)
|
||||
monkeypatch.setattr(route, "ENABLE_PARTNER_SUGGESTIONS", True)
|
||||
|
||||
captured: dict[str, bool] = {"refresh": False}
|
||||
|
||||
def fake_get_partner_suggestions(
|
||||
commander_name: str,
|
||||
*,
|
||||
limit_per_mode: int = 5,
|
||||
include_modes=None,
|
||||
min_score: float = 0.15,
|
||||
refresh_dataset: bool = False,
|
||||
) -> PartnerSuggestionResult:
|
||||
captured["refresh"] = refresh_dataset
|
||||
return PartnerSuggestionResult(
|
||||
commander=commander_name,
|
||||
display_name=commander_name,
|
||||
canonical=commander_name.casefold(),
|
||||
metadata={},
|
||||
by_mode={},
|
||||
total=0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(route, "get_partner_suggestions", fake_get_partner_suggestions)
|
||||
|
||||
request = _make_request()
|
||||
|
||||
response = asyncio.run(
|
||||
route.partner_suggestions_api(
|
||||
request,
|
||||
commander="Akiri, Line-Slinger",
|
||||
limit=5,
|
||||
visible_limit=3,
|
||||
include_hidden=False,
|
||||
partner=None,
|
||||
background=None,
|
||||
mode=None,
|
||||
refresh=False,
|
||||
)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert captured["refresh"] is False
|
||||
|
||||
response_refresh = asyncio.run(
|
||||
route.partner_suggestions_api(
|
||||
_make_request(query_string="refresh=1"),
|
||||
commander="Akiri, Line-Slinger",
|
||||
limit=5,
|
||||
visible_limit=3,
|
||||
include_hidden=False,
|
||||
partner=None,
|
||||
background=None,
|
||||
mode=None,
|
||||
refresh=True,
|
||||
)
|
||||
)
|
||||
assert response_refresh.status_code == 200
|
||||
assert captured["refresh"] is True
|
||||
163
code/tests/test_partner_suggestions_pipeline.py
Normal file
163
code/tests/test_partner_suggestions_pipeline.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from code.scripts import build_partner_suggestions as pipeline
|
||||
|
||||
|
||||
CSV_CONTENT = """name,faceName,colorIdentity,themeTags,roleTags,text,type,partnerWith,supportsBackgrounds,isPartner,isBackground,isDoctor,isDoctorsCompanion
|
||||
"Halana, Kessig Ranger","Halana, Kessig Ranger","['G']","['Counters','Partner']","['Aggro']","Reach. Partner with Alena, Kessig Trapper.","Legendary Creature — Human Archer","['Alena, Kessig Trapper']",False,True,False,False,False
|
||||
"Alena, Kessig Trapper","Alena, Kessig Trapper","['R']","['Aggro','Partner']","['Ramp']","First strike. Partner with Halana, Kessig Ranger.","Legendary Creature — Human Scout","['Halana, Kessig Ranger']",False,True,False,False,False
|
||||
"Wilson, Refined Grizzly","Wilson, Refined Grizzly","['G']","['Teamwork','Backgrounds Matter']","['Aggro']","Choose a Background (You can have a Background as a second commander.)","Legendary Creature — Bear Warrior","[]",True,False,False,False,False
|
||||
"Guild Artisan","Guild Artisan","['R']","['Background']","[]","Commander creatures you own have \"Whenever this creature attacks...\"","Legendary Enchantment — Background","[]",False,False,True,False,False
|
||||
"The Tenth Doctor","The Tenth Doctor","['U','R','G']","['Time Travel']","[]","Doctor's companion (You can have two commanders if the other is a Doctor's companion.)","Legendary Creature — Time Lord Doctor","[]",False,False,False,True,False
|
||||
"Rose Tyler","Rose Tyler","['W']","['Companions']","[]","Doctor's companion","Legendary Creature — Human","[]",False,False,False,False,True
|
||||
"""
|
||||
|
||||
|
||||
def _write_summary(path: Path, primary: str, secondary: str | None, mode: str, tags: list[str]) -> None:
|
||||
payload = {
|
||||
"meta": {
|
||||
"commander": primary,
|
||||
"tags": tags,
|
||||
},
|
||||
"summary": {
|
||||
"commander": {
|
||||
"names": [name for name in [primary, secondary] if name],
|
||||
"primary": primary,
|
||||
"secondary": secondary,
|
||||
"partner_mode": mode,
|
||||
"color_identity": [],
|
||||
"combined": {
|
||||
"primary_name": primary,
|
||||
"secondary_name": secondary,
|
||||
"partner_mode": mode,
|
||||
"color_identity": [],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_text(path: Path, primary: str, secondary: str | None, mode: str) -> None:
|
||||
lines = []
|
||||
if secondary:
|
||||
lines.append(f"# Commanders: {primary}, {secondary}")
|
||||
else:
|
||||
lines.append(f"# Commander: {primary}")
|
||||
lines.append(f"# Partner Mode: {mode}")
|
||||
lines.append(f"1 {primary}")
|
||||
if secondary:
|
||||
lines.append(f"1 {secondary}")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_build_partner_suggestions_creates_dataset(tmp_path: Path) -> None:
|
||||
commander_csv = tmp_path / "commander_cards.csv"
|
||||
commander_csv.write_text(CSV_CONTENT, encoding="utf-8")
|
||||
|
||||
deck_dir = tmp_path / "deck_files"
|
||||
deck_dir.mkdir()
|
||||
|
||||
# Partner deck
|
||||
_write_summary(
|
||||
deck_dir / "halana_partner.summary.json",
|
||||
primary="Halana, Kessig Ranger",
|
||||
secondary="Alena, Kessig Trapper",
|
||||
mode="partner",
|
||||
tags=["Counters", "Aggro"],
|
||||
)
|
||||
_write_text(
|
||||
deck_dir / "halana_partner.txt",
|
||||
primary="Halana, Kessig Ranger",
|
||||
secondary="Alena, Kessig Trapper",
|
||||
mode="partner",
|
||||
)
|
||||
|
||||
# Background deck
|
||||
_write_summary(
|
||||
deck_dir / "wilson_background.summary.json",
|
||||
primary="Wilson, Refined Grizzly",
|
||||
secondary="Guild Artisan",
|
||||
mode="background",
|
||||
tags=["Teamwork", "Aggro"],
|
||||
)
|
||||
_write_text(
|
||||
deck_dir / "wilson_background.txt",
|
||||
primary="Wilson, Refined Grizzly",
|
||||
secondary="Guild Artisan",
|
||||
mode="background",
|
||||
)
|
||||
|
||||
# Doctor/Companion deck
|
||||
_write_summary(
|
||||
deck_dir / "doctor_companion.summary.json",
|
||||
primary="The Tenth Doctor",
|
||||
secondary="Rose Tyler",
|
||||
mode="doctor_companion",
|
||||
tags=["Time Travel", "Companions"],
|
||||
)
|
||||
_write_text(
|
||||
deck_dir / "doctor_companion.txt",
|
||||
primary="The Tenth Doctor",
|
||||
secondary="Rose Tyler",
|
||||
mode="doctor_companion",
|
||||
)
|
||||
|
||||
output_path = tmp_path / "partner_synergy.json"
|
||||
result = pipeline.build_partner_suggestions(
|
||||
commander_csv=commander_csv,
|
||||
deck_dir=deck_dir,
|
||||
output_path=output_path,
|
||||
max_examples=3,
|
||||
)
|
||||
|
||||
assert output_path.exists(), "Expected partner synergy dataset to be created"
|
||||
data = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
|
||||
metadata = data["metadata"]
|
||||
assert metadata["deck_exports_processed"] == 3
|
||||
assert metadata["deck_exports_with_pairs"] == 3
|
||||
assert "version_hash" in metadata
|
||||
|
||||
overrides = data["curated_overrides"]
|
||||
assert overrides["version"] == metadata["version_hash"]
|
||||
assert overrides["entries"] == {}
|
||||
|
||||
mode_counts = data["pairings"]["mode_counts"]
|
||||
assert mode_counts == {
|
||||
"background": 1,
|
||||
"doctor_companion": 1,
|
||||
"partner": 1,
|
||||
}
|
||||
|
||||
records = data["pairings"]["records"]
|
||||
partner_entry = next(item for item in records if item["mode"] == "partner")
|
||||
assert partner_entry["primary"] == "Halana, Kessig Ranger"
|
||||
assert partner_entry["secondary"] == "Alena, Kessig Trapper"
|
||||
assert partner_entry["combined_colors"] == ["R", "G"]
|
||||
|
||||
commanders = data["commanders"]
|
||||
halana = commanders["halana, kessig ranger"]
|
||||
assert halana["partner"]["has_partner"] is True
|
||||
guild_artisan = commanders["guild artisan"]
|
||||
assert guild_artisan["partner"]["is_background"] is True
|
||||
|
||||
themes = data["themes"]
|
||||
aggro = themes["aggro"]
|
||||
assert aggro["deck_count"] == 2
|
||||
assert set(aggro["co_occurrence"].keys()) == {"counters", "teamwork"}
|
||||
|
||||
doctor_usage = commanders["the tenth doctor"]["usage"]
|
||||
assert doctor_usage == {"primary": 1, "secondary": 0, "total": 1}
|
||||
|
||||
rose_usage = commanders["rose tyler"]["usage"]
|
||||
assert rose_usage == {"primary": 0, "secondary": 1, "total": 1}
|
||||
|
||||
partner_tags = partner_entry["tags"]
|
||||
assert partner_tags == ["Aggro", "Counters"]
|
||||
|
||||
# round-trip result returned from function should mirror file payload
|
||||
assert result == data
|
||||
133
code/tests/test_partner_suggestions_service.py
Normal file
133
code/tests/test_partner_suggestions_service.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from code.web.services.partner_suggestions import (
|
||||
configure_dataset_path,
|
||||
get_partner_suggestions,
|
||||
)
|
||||
|
||||
|
||||
def _write_dataset(path: Path) -> Path:
|
||||
payload = {
|
||||
"metadata": {
|
||||
"generated_at": "2025-10-06T12:00:00Z",
|
||||
"version": "test-fixture",
|
||||
},
|
||||
"commanders": {
|
||||
"akiri_line_slinger": {
|
||||
"name": "Akiri, Line-Slinger",
|
||||
"display_name": "Akiri, Line-Slinger",
|
||||
"color_identity": ["R", "W"],
|
||||
"themes": ["Artifacts", "Aggro", "Legends Matter", "Partner"],
|
||||
"role_tags": ["Aggro"],
|
||||
"partner": {
|
||||
"has_partner": True,
|
||||
"partner_with": ["Silas Renn, Seeker Adept"],
|
||||
"supports_backgrounds": False,
|
||||
},
|
||||
},
|
||||
"silas_renn_seeker_adept": {
|
||||
"name": "Silas Renn, Seeker Adept",
|
||||
"display_name": "Silas Renn, Seeker Adept",
|
||||
"color_identity": ["U", "B"],
|
||||
"themes": ["Artifacts", "Value"],
|
||||
"role_tags": ["Value"],
|
||||
"partner": {
|
||||
"has_partner": True,
|
||||
"partner_with": ["Akiri, Line-Slinger"],
|
||||
"supports_backgrounds": False,
|
||||
},
|
||||
},
|
||||
"ishai_ojutai_dragonspeaker": {
|
||||
"name": "Ishai, Ojutai Dragonspeaker",
|
||||
"display_name": "Ishai, Ojutai Dragonspeaker",
|
||||
"color_identity": ["W", "U"],
|
||||
"themes": ["Artifacts", "Counters", "Historics Matter", "Partner - Survivors"],
|
||||
"role_tags": ["Aggro"],
|
||||
"partner": {
|
||||
"has_partner": True,
|
||||
"partner_with": [],
|
||||
"supports_backgrounds": False,
|
||||
},
|
||||
},
|
||||
"reyhan_last_of_the_abzan": {
|
||||
"name": "Reyhan, Last of the Abzan",
|
||||
"display_name": "Reyhan, Last of the Abzan",
|
||||
"color_identity": ["B", "G"],
|
||||
"themes": ["Counters", "Artifacts", "Partner"],
|
||||
"role_tags": ["Counters"],
|
||||
"partner": {
|
||||
"has_partner": True,
|
||||
"partner_with": [],
|
||||
"supports_backgrounds": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"pairings": {
|
||||
"records": [
|
||||
{
|
||||
"mode": "partner_with",
|
||||
"primary_canonical": "akiri_line_slinger",
|
||||
"secondary_canonical": "silas_renn_seeker_adept",
|
||||
"count": 12,
|
||||
},
|
||||
{
|
||||
"mode": "partner",
|
||||
"primary_canonical": "akiri_line_slinger",
|
||||
"secondary_canonical": "ishai_ojutai_dragonspeaker",
|
||||
"count": 6,
|
||||
},
|
||||
{
|
||||
"mode": "partner",
|
||||
"primary_canonical": "akiri_line_slinger",
|
||||
"secondary_canonical": "reyhan_last_of_the_abzan",
|
||||
"count": 4,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_get_partner_suggestions_produces_visible_and_hidden(tmp_path: Path) -> None:
|
||||
dataset_path = _write_dataset(tmp_path / "partner_synergy.json")
|
||||
try:
|
||||
configure_dataset_path(dataset_path)
|
||||
result = get_partner_suggestions("Akiri, Line-Slinger", limit_per_mode=5)
|
||||
assert result is not None
|
||||
assert result.total >= 3
|
||||
partner_names = [
|
||||
"Silas Renn, Seeker Adept",
|
||||
"Ishai, Ojutai Dragonspeaker",
|
||||
"Reyhan, Last of the Abzan",
|
||||
]
|
||||
visible, hidden = result.flatten(partner_names, [], visible_limit=2)
|
||||
assert len(visible) == 2
|
||||
assert any(item["name"] == "Silas Renn, Seeker Adept" for item in visible)
|
||||
assert hidden, "expected additional hidden suggestions"
|
||||
assert result.metadata.get("generated_at") == "2025-10-06T12:00:00Z"
|
||||
finally:
|
||||
configure_dataset_path(None)
|
||||
|
||||
|
||||
def test_noise_themes_suppressed_in_shared_theme_summary(tmp_path: Path) -> None:
|
||||
dataset_path = _write_dataset(tmp_path / "partner_synergy.json")
|
||||
try:
|
||||
configure_dataset_path(dataset_path)
|
||||
result = get_partner_suggestions("Akiri, Line-Slinger", limit_per_mode=5)
|
||||
assert result is not None
|
||||
partner_entries = result.by_mode.get("partner") or []
|
||||
target = next((entry for entry in partner_entries if entry["name"] == "Ishai, Ojutai Dragonspeaker"), None)
|
||||
assert target is not None, "expected Ishai suggestions to be present"
|
||||
assert "Legends Matter" not in target["shared_themes"]
|
||||
assert "Historics Matter" not in target["shared_themes"]
|
||||
assert "Partner" not in target["shared_themes"]
|
||||
assert "Partner - Survivors" not in target["shared_themes"]
|
||||
assert all(theme not in {"Legends Matter", "Historics Matter", "Partner", "Partner - Survivors"} for theme in target["candidate_themes"])
|
||||
assert "Legends Matter" not in target["summary"]
|
||||
assert "Partner" not in target["summary"]
|
||||
finally:
|
||||
configure_dataset_path(None)
|
||||
98
code/tests/test_partner_suggestions_telemetry.py
Normal file
98
code/tests/test_partner_suggestions_telemetry.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import json
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from code.web.services.telemetry import (
|
||||
log_partner_suggestion_selected,
|
||||
log_partner_suggestions_generated,
|
||||
)
|
||||
|
||||
|
||||
async def _receive() -> Dict[str, Any]:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
|
||||
def _make_request(path: str, method: str = "GET", query_string: str = "") -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode("utf-8"),
|
||||
"query_string": query_string.encode("utf-8"),
|
||||
"headers": [],
|
||||
"client": ("203.0.113.5", 52345),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
request = Request(scope, receive=_receive)
|
||||
request.state.request_id = "req-123"
|
||||
return request
|
||||
|
||||
|
||||
def test_log_partner_suggestions_generated_emits_payload(caplog: pytest.LogCaptureFixture) -> None:
|
||||
request = _make_request("/api/partner/suggestions", query_string="commander=Akiri&mode=partner")
|
||||
metadata = {"dataset_version": "2025-10-05", "record_count": 42}
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="web.partner_suggestions"):
|
||||
log_partner_suggestions_generated(
|
||||
request,
|
||||
commander_display="Akiri, Fearless Voyager",
|
||||
commander_canonical="akiri, fearless voyager",
|
||||
include_modes=["partner"],
|
||||
available_modes=["partner"],
|
||||
total=3,
|
||||
mode_counts={"partner": 3},
|
||||
visible_count=2,
|
||||
hidden_count=1,
|
||||
limit_per_mode=5,
|
||||
visible_limit=3,
|
||||
include_hidden=False,
|
||||
refresh_requested=False,
|
||||
dataset_metadata=metadata,
|
||||
)
|
||||
|
||||
matching = [record for record in caplog.records if record.name == "web.partner_suggestions"]
|
||||
assert matching, "Expected partner suggestions telemetry log"
|
||||
payload = json.loads(matching[-1].message)
|
||||
assert payload["event"] == "partner_suggestions.generated"
|
||||
assert payload["commander"]["display"] == "Akiri, Fearless Voyager"
|
||||
assert payload["filters"]["include_modes"] == ["partner"]
|
||||
assert payload["result"]["mode_counts"]["partner"] == 3
|
||||
assert payload["result"]["visible_count"] == 2
|
||||
assert payload["result"]["metadata"]["dataset_version"] == "2025-10-05"
|
||||
assert payload["query"]["mode"] == "partner"
|
||||
|
||||
|
||||
def test_log_partner_suggestion_selected_emits_payload(caplog: pytest.LogCaptureFixture) -> None:
|
||||
request = _make_request("/build/partner/preview", method="POST")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="web.partner_suggestions"):
|
||||
log_partner_suggestion_selected(
|
||||
request,
|
||||
commander="Rograkh, Son of Rohgahh",
|
||||
scope="partner",
|
||||
partner_enabled=True,
|
||||
auto_opt_out=False,
|
||||
auto_assigned=False,
|
||||
selection_source="suggestion",
|
||||
secondary_candidate="Silas Renn, Seeker Adept",
|
||||
background_candidate=None,
|
||||
resolved_secondary="Silas Renn, Seeker Adept",
|
||||
resolved_background=None,
|
||||
partner_mode="partner",
|
||||
has_preview=True,
|
||||
warnings=["Color identity expanded"],
|
||||
error=None,
|
||||
)
|
||||
|
||||
matching = [record for record in caplog.records if record.name == "web.partner_suggestions"]
|
||||
assert matching, "Expected partner suggestion selection telemetry log"
|
||||
payload = json.loads(matching[-1].message)
|
||||
assert payload["event"] == "partner_suggestions.selected"
|
||||
assert payload["selection_source"] == "suggestion"
|
||||
assert payload["resolved"]["partner_mode"] == "partner"
|
||||
assert payload["warnings_count"] == 1
|
||||
assert payload["has_error"] is False
|
||||
91
code/tests/test_partner_synergy_refresh.py
Normal file
91
code/tests/test_partner_synergy_refresh.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from code.web.services import orchestrator
|
||||
|
||||
|
||||
def _setup_fake_root(tmp_path: Path) -> Path:
|
||||
root = tmp_path
|
||||
scripts_dir = root / "code" / "scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
(scripts_dir / "build_partner_suggestions.py").write_text("print('noop')\n", encoding="utf-8")
|
||||
|
||||
(root / "config" / "themes").mkdir(parents=True, exist_ok=True)
|
||||
(root / "csv_files").mkdir(parents=True, exist_ok=True)
|
||||
(root / "deck_files").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(root / "config" / "themes" / "theme_list.json").write_text("{}\n", encoding="utf-8")
|
||||
(root / "csv_files" / "commander_cards.csv").write_text("name\nTest Commander\n", encoding="utf-8")
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def _invoke_helper(
|
||||
root: Path,
|
||||
monkeypatch,
|
||||
*,
|
||||
force: bool = False,
|
||||
out_func: Optional[Callable[[str], None]] = None,
|
||||
) -> list[tuple[list[str], str]]:
|
||||
calls: list[tuple[list[str], str]] = []
|
||||
|
||||
def _fake_run(cmd, check=False, cwd=None): # type: ignore[no-untyped-def]
|
||||
calls.append((list(cmd), cwd))
|
||||
class _Completed:
|
||||
returncode = 0
|
||||
return _Completed()
|
||||
|
||||
monkeypatch.setattr(orchestrator.subprocess, "run", _fake_run)
|
||||
orchestrator._maybe_refresh_partner_synergy(out_func, force=force, root=str(root))
|
||||
return calls
|
||||
|
||||
|
||||
def test_partner_synergy_refresh_invokes_script_when_missing(tmp_path, monkeypatch) -> None:
|
||||
root = _setup_fake_root(tmp_path)
|
||||
calls = _invoke_helper(root, monkeypatch, force=False)
|
||||
assert len(calls) == 1
|
||||
cmd, cwd = calls[0]
|
||||
assert cmd[0] == orchestrator.sys.executable
|
||||
assert cmd[1].endswith("build_partner_suggestions.py")
|
||||
assert cwd == str(root)
|
||||
|
||||
|
||||
def test_partner_synergy_refresh_skips_when_dataset_fresh(tmp_path, monkeypatch) -> None:
|
||||
root = _setup_fake_root(tmp_path)
|
||||
analytics_dir = root / "config" / "analytics"
|
||||
analytics_dir.mkdir(parents=True, exist_ok=True)
|
||||
dataset = analytics_dir / "partner_synergy.json"
|
||||
dataset.write_text("{}\n", encoding="utf-8")
|
||||
|
||||
now = time.time()
|
||||
os.utime(dataset, (now, now))
|
||||
source_time = now - 120
|
||||
for rel in ("config/themes/theme_list.json", "csv_files/commander_cards.csv"):
|
||||
src = root / rel
|
||||
os.utime(src, (source_time, source_time))
|
||||
|
||||
calls = _invoke_helper(root, monkeypatch, force=False)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_partner_synergy_refresh_honors_force_flag(tmp_path, monkeypatch) -> None:
|
||||
root = _setup_fake_root(tmp_path)
|
||||
analytics_dir = root / "config" / "analytics"
|
||||
analytics_dir.mkdir(parents=True, exist_ok=True)
|
||||
dataset = analytics_dir / "partner_synergy.json"
|
||||
dataset.write_text("{}\n", encoding="utf-8")
|
||||
now = time.time()
|
||||
os.utime(dataset, (now, now))
|
||||
for rel in ("config/themes/theme_list.json", "csv_files/commander_cards.csv"):
|
||||
src = root / rel
|
||||
os.utime(src, (now, now))
|
||||
|
||||
calls = _invoke_helper(root, monkeypatch, force=True)
|
||||
assert len(calls) == 1
|
||||
cmd, cwd = calls[0]
|
||||
assert cmd[1].endswith("build_partner_suggestions.py")
|
||||
assert cwd == str(root)
|
||||
27
code/tests/test_web_background_fallback.py
Normal file
27
code/tests/test_web_background_fallback.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Tests for background option fallback logic in the web build route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from code.web import app # noqa: F401 # Ensure app is initialized prior to build import
|
||||
from code.web.routes import build
|
||||
from code.web.services.commander_catalog_loader import find_commander_record
|
||||
|
||||
|
||||
def test_build_background_options_falls_back_to_commander_catalog(monkeypatch):
|
||||
"""When the background CSV is unavailable, commander catalog data is used."""
|
||||
|
||||
def _raise_missing(*_args, **_kwargs):
|
||||
raise FileNotFoundError("missing background csv")
|
||||
|
||||
monkeypatch.setattr(build, "load_background_cards", _raise_missing)
|
||||
|
||||
options = build._build_background_options()
|
||||
|
||||
assert options, "Expected fallback to provide background options"
|
||||
names = [opt["name"] for opt in options]
|
||||
assert len(names) == len(set(name.casefold() for name in names)), "Background options should be unique"
|
||||
|
||||
for name in names:
|
||||
record = find_commander_record(name)
|
||||
assert record is not None, f"Commander catalog missing background record for {name}"
|
||||
assert record.is_background, f"Expected {name} to be marked as a Background"
|
||||
299
code/tests/test_web_new_deck_partner.py
Normal file
299
code/tests/test_web_new_deck_partner.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder.partner_selection import apply_partner_inputs
|
||||
|
||||
|
||||
def _fresh_client() -> TestClient:
|
||||
os.environ["ENABLE_PARTNER_MECHANICS"] = "1"
|
||||
# Ensure a fresh app import so feature flags are applied
|
||||
for module in ("code.web.app", "code.web.routes.build"):
|
||||
if module in sys.modules:
|
||||
del sys.modules[module]
|
||||
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
|
||||
|
||||
clear_commander_catalog_cache()
|
||||
from code.web.app import app # type: ignore
|
||||
|
||||
client = TestClient(app)
|
||||
from code.web.services import tasks
|
||||
|
||||
tasks._SESSIONS.clear()
|
||||
return client
|
||||
|
||||
|
||||
def _first_commander_tag(commander_name: str) -> str | None:
|
||||
from code.web.services import orchestrator as orch
|
||||
|
||||
tags: Iterable[str] = orch.tags_for_commander(commander_name) or []
|
||||
for tag in tags:
|
||||
value = str(tag).strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
_OPTION_PATTERN = re.compile(r'<option value="([^\"]*)" data-pairing-mode="([^\"]]*)"[^>]*data-role-label="([^\"]*)"', re.IGNORECASE)
|
||||
_OPTION_PATTERN = re.compile(r'<option[^>]*value="([^"]+)"[^>]*data-pairing-mode="([^"]+)"[^>]*data-role-label="([^"]+)"', re.IGNORECASE)
|
||||
|
||||
def _partner_option_rows(html: str) -> list[tuple[str, str, str]]:
|
||||
rows = []
|
||||
for name, mode, role in _OPTION_PATTERN.findall(html or ""):
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
continue
|
||||
rows.append((clean_name, mode.strip(), role.strip()))
|
||||
return rows
|
||||
|
||||
|
||||
def test_new_deck_inspect_includes_partner_controls() -> None:
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
resp = client.get("/build/new/inspect", params={"name": "Akiri, Line-Slinger"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "Partner commander" in body
|
||||
assert "type=\"checkbox\"" not in body
|
||||
assert "Silas Renn" in body # partner list should surface another partner option
|
||||
assert 'data-image-url="' in body
|
||||
|
||||
|
||||
def test_partner_with_dropdown_limits_to_pair() -> None:
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
resp = client.get("/build/new/inspect", params={"name": "Evie Frye"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
assert "Automatically paired with Jacob Frye" in body
|
||||
partner_rows = re.findall(r'<option value="([^"]+)" data-pairing-mode="([^"]+)"', body)
|
||||
assert partner_rows == [("Jacob Frye", "partner_with")]
|
||||
assert "Silas Renn" not in body
|
||||
|
||||
|
||||
def test_new_deck_submit_persists_partner_selection() -> None:
|
||||
commander = "Akiri, Line-Slinger"
|
||||
secondary = "Silas Renn, Seeker Adept"
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
primary_tag = _first_commander_tag(commander)
|
||||
form_data = {
|
||||
"name": "Akiri Partner Test",
|
||||
"commander": commander,
|
||||
"partner_enabled": "1",
|
||||
"secondary_commander": secondary,
|
||||
"partner_auto_opt_out": "0",
|
||||
"bracket": "3",
|
||||
}
|
||||
if primary_tag:
|
||||
form_data["primary_tag"] = primary_tag
|
||||
resp = client.post("/build/new", data=form_data)
|
||||
assert resp.status_code == 200
|
||||
assert "Stage complete" in resp.text or "Build complete" in resp.text
|
||||
|
||||
from code.web.services import tasks
|
||||
|
||||
sid = client.cookies.get("sid")
|
||||
assert sid, "expected sid cookie after submission"
|
||||
sess = tasks._SESSIONS.get(sid)
|
||||
assert sess is not None, "session should exist for sid"
|
||||
assert sess.get("partner_enabled") is True
|
||||
assert sess.get("secondary_commander") == secondary
|
||||
assert sess.get("partner_mode") in {"partner", "partner_with"}
|
||||
combined = sess.get("combined_commander")
|
||||
assert isinstance(combined, dict)
|
||||
assert combined.get("secondary_name") == secondary
|
||||
assert sess.get("partner_auto_opt_out") is False
|
||||
assert sess.get("partner_auto_assigned") is False
|
||||
# cleanup
|
||||
tasks._SESSIONS.pop(sid, None)
|
||||
|
||||
|
||||
def test_doctor_companion_flow() -> None:
|
||||
commander = "The Tenth Doctor"
|
||||
companion = "Donna Noble"
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
inspect = client.get("/build/new/inspect", params={"name": commander})
|
||||
assert inspect.status_code == 200
|
||||
body = inspect.text
|
||||
assert "Companion" in body
|
||||
assert companion in body
|
||||
assert re.search(r"<button[^>]*data-partner-autotoggle", body) is None # Doctor pairings should not auto-toggle
|
||||
|
||||
primary_tag = _first_commander_tag(commander)
|
||||
form_data = {
|
||||
"name": "Doctor Companion Test",
|
||||
"commander": commander,
|
||||
"partner_enabled": "1",
|
||||
"secondary_commander": companion,
|
||||
"partner_auto_opt_out": "0",
|
||||
"bracket": "3",
|
||||
}
|
||||
if primary_tag:
|
||||
form_data["primary_tag"] = primary_tag
|
||||
resp = client.post("/build/new", data=form_data)
|
||||
assert resp.status_code == 200
|
||||
|
||||
from code.web.services import tasks
|
||||
|
||||
sid = client.cookies.get("sid")
|
||||
assert sid, "expected sid cookie after submission"
|
||||
sess = tasks._SESSIONS.get(sid)
|
||||
assert sess is not None
|
||||
assert sess.get("partner_mode") == "doctor_companion"
|
||||
assert sess.get("secondary_commander") == companion
|
||||
tasks._SESSIONS.pop(sid, None)
|
||||
|
||||
|
||||
def test_amy_partner_options_include_rory_and_only_doctors() -> None:
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
resp = client.get("/build/new/inspect", params={"name": "Amy Pond"})
|
||||
assert resp.status_code == 200
|
||||
rows = _partner_option_rows(resp.text)
|
||||
|
||||
partner_with_rows = [row for row in rows if row[1] == "partner_with"]
|
||||
assert any(name == "Rory Williams" for name, _, _ in partner_with_rows)
|
||||
assert len(partner_with_rows) == 1
|
||||
|
||||
for name, mode, role in rows:
|
||||
if name == "Rory Williams":
|
||||
continue
|
||||
assert mode == "doctor_companion"
|
||||
assert "Doctor" in role
|
||||
assert "Companion" not in role
|
||||
|
||||
|
||||
def test_donna_partner_options_only_list_doctors() -> None:
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
resp = client.get("/build/new/inspect", params={"name": "Donna Noble"})
|
||||
assert resp.status_code == 200
|
||||
rows = _partner_option_rows(resp.text)
|
||||
|
||||
assert rows, "expected Doctor options for Donna"
|
||||
for name, mode, role in rows:
|
||||
assert mode == "doctor_companion"
|
||||
assert "Doctor" in role
|
||||
assert "Companion" not in role
|
||||
|
||||
|
||||
def test_rory_partner_options_only_include_amy() -> None:
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
resp = client.get("/build/new/inspect", params={"name": "Rory Williams"})
|
||||
assert resp.status_code == 200
|
||||
rows = _partner_option_rows(resp.text)
|
||||
|
||||
assert rows == [("Amy Pond", "partner_with", "Partner With")]
|
||||
|
||||
|
||||
def test_step2_tags_merge_partner_union() -> None:
|
||||
commander = "Akiri, Line-Slinger"
|
||||
secondary = "Silas Renn, Seeker Adept"
|
||||
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||
combined = apply_partner_inputs(
|
||||
builder,
|
||||
primary_name=commander,
|
||||
secondary_name=secondary,
|
||||
feature_enabled=True,
|
||||
)
|
||||
expected_tags = set(combined.theme_tags if combined else ())
|
||||
assert expected_tags, "expected combined commander to produce theme tags"
|
||||
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
primary_tag = _first_commander_tag(commander)
|
||||
form_data = {
|
||||
"name": "Tag Merge",
|
||||
"commander": commander,
|
||||
"partner_enabled": "1",
|
||||
"secondary_commander": secondary,
|
||||
"partner_auto_opt_out": "0",
|
||||
"bracket": "3",
|
||||
}
|
||||
if primary_tag:
|
||||
form_data["primary_tag"] = primary_tag
|
||||
client.post("/build/new", data=form_data)
|
||||
|
||||
resp = client.get("/build/step2")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
for tag in expected_tags:
|
||||
assert tag in body
|
||||
|
||||
|
||||
def test_step5_summary_displays_combined_partner_details() -> None:
|
||||
commander = "Halana, Kessig Ranger"
|
||||
secondary = "Alena, Kessig Trapper"
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
primary_tag = _first_commander_tag(commander)
|
||||
form_data = {
|
||||
"name": "Halana Alena Partner",
|
||||
"commander": commander,
|
||||
"partner_enabled": "1",
|
||||
"secondary_commander": secondary,
|
||||
"partner_auto_opt_out": "0",
|
||||
"bracket": "3",
|
||||
}
|
||||
if primary_tag:
|
||||
form_data["primary_tag"] = primary_tag
|
||||
resp = client.post("/build/new", data=form_data)
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
assert "Halana, Kessig Ranger + Alena, Kessig Trapper" in body
|
||||
assert "mana-R" in body and "mana-G" in body
|
||||
assert "Burn" in body
|
||||
assert "commander-card partner-card" in body
|
||||
assert 'data-card-name="Alena, Kessig Trapper"' in body
|
||||
assert 'width="320"' in body
|
||||
|
||||
|
||||
def test_partner_preview_endpoint_returns_theme_tags() -> None:
|
||||
commander = "Akiri, Line-Slinger"
|
||||
secondary = "Silas Renn, Seeker Adept"
|
||||
client = _fresh_client()
|
||||
with client:
|
||||
client.get("/build/new")
|
||||
resp = client.post(
|
||||
"/build/partner/preview",
|
||||
data={
|
||||
"commander": commander,
|
||||
"partner_enabled": "1",
|
||||
"secondary_commander": secondary,
|
||||
"partner_auto_opt_out": "0",
|
||||
"scope": "step2",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
|
||||
assert payload.get("ok") is True
|
||||
preview = payload.get("preview") or {}
|
||||
assert preview.get("secondary_name") == secondary
|
||||
assert preview.get("partner_mode") in {"partner", "partner_with"}
|
||||
tags = payload.get("theme_tags") or []
|
||||
assert isinstance(tags, list)
|
||||
assert tags, "expected theme tags from partner preview"
|
||||
assert payload.get("scope") == "step2"
|
||||
assert preview.get("secondary_image_url")
|
||||
assert preview.get("secondary_role_label")
|
||||
|
|
@ -16,7 +16,7 @@ from starlette.middleware.gzip import GZipMiddleware
|
|||
from typing import Any, Optional, Dict, Iterable, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_theme_metrics
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics
|
||||
from tagging.multi_face_merger import load_merge_summary
|
||||
from .services.combo_utils import detect_all as _detect_all
|
||||
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
|
||||
|
|
@ -113,6 +113,8 @@ ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
|||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
||||
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
|
||||
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True)
|
||||
ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True)
|
||||
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)
|
||||
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
|
||||
THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False)
|
||||
|
|
@ -246,6 +248,8 @@ templates.env.globals.update({
|
|||
"enable_pwa": ENABLE_PWA,
|
||||
"enable_presets": ENABLE_PRESETS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_partner_mechanics": ENABLE_PARTNER_MECHANICS,
|
||||
"enable_partner_suggestions": ENABLE_PARTNER_SUGGESTIONS,
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"default_theme": DEFAULT_THEME,
|
||||
"random_modes": RANDOM_MODES,
|
||||
|
|
@ -834,6 +838,7 @@ async def status_sys():
|
|||
"ENABLE_CUSTOM_THEMES": bool(ENABLE_CUSTOM_THEMES),
|
||||
"ENABLE_PWA": bool(ENABLE_PWA),
|
||||
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
|
||||
"ENABLE_PARTNER_MECHANICS": bool(ENABLE_PARTNER_MECHANICS),
|
||||
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
|
||||
"DEFAULT_THEME": DEFAULT_THEME,
|
||||
"THEME_MATCH_MODE": DEFAULT_THEME_MATCH_MODE,
|
||||
|
|
@ -910,6 +915,17 @@ async def status_theme_metrics():
|
|||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/status/partner_metrics")
|
||||
async def status_partner_metrics():
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
try:
|
||||
return JSONResponse({"ok": True, "metrics": get_partner_metrics()})
|
||||
except Exception as exc: # pragma: no cover - defensive log
|
||||
logging.getLogger("web").warning("Failed to fetch partner metrics: %s", exc, exc_info=True)
|
||||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
def random_modes_enabled() -> bool:
|
||||
"""Dynamic check so tests that set env after import still work.
|
||||
|
||||
|
|
@ -2169,6 +2185,7 @@ from .routes import setup as setup_routes # noqa: E402
|
|||
from .routes import owned as owned_routes # noqa: E402
|
||||
from .routes import themes as themes_routes # noqa: E402
|
||||
from .routes import commanders as commanders_routes # noqa: E402
|
||||
from .routes import partner_suggestions as partner_suggestions_routes # noqa: E402
|
||||
app.include_router(build_routes.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(decks_routes.router)
|
||||
|
|
@ -2176,6 +2193,7 @@ app.include_router(setup_routes.router)
|
|||
app.include_router(owned_routes.router)
|
||||
app.include_router(themes_routes.router)
|
||||
app.include_router(commanders_routes.router)
|
||||
app.include_router(partner_suggestions_routes.router)
|
||||
|
||||
# Warm validation cache early to reduce first-call latency in tests and dev
|
||||
try:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -153,8 +153,10 @@ def _partner_summary(record: CommanderRecord) -> tuple[str, ...]:
|
|||
parts: list[str] = []
|
||||
if record.partner_with:
|
||||
parts.append("Partner with " + ", ".join(record.partner_with))
|
||||
elif record.is_partner:
|
||||
elif getattr(record, "has_plain_partner", False):
|
||||
parts.append("Partner available")
|
||||
elif record.is_partner:
|
||||
parts.append("Partner (restricted)")
|
||||
if record.supports_backgrounds:
|
||||
parts.append("Choose a Background")
|
||||
if record.is_background:
|
||||
|
|
|
|||
160
code/web/routes/partner_suggestions.py
Normal file
160
code/web/routes/partner_suggestions.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from deck_builder.combined_commander import PartnerMode
|
||||
|
||||
from ..app import ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS
|
||||
from ..services.partner_suggestions import get_partner_suggestions
|
||||
from ..services.telemetry import log_partner_suggestions_generated
|
||||
|
||||
router = APIRouter(prefix="/api/partner", tags=["partner suggestions"])
|
||||
|
||||
|
||||
def _parse_modes(values: Optional[Iterable[str]]) -> list[PartnerMode]:
|
||||
if not values:
|
||||
return []
|
||||
modes: list[PartnerMode] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
normalized = str(value).strip().replace("-", "_").lower()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
for mode in PartnerMode:
|
||||
if mode.value == normalized:
|
||||
modes.append(mode)
|
||||
break
|
||||
return modes
|
||||
|
||||
|
||||
def _coerce_name_list(values: Optional[Iterable[str]]) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
continue
|
||||
key = text.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/suggestions")
|
||||
async def partner_suggestions_api(
|
||||
request: Request,
|
||||
commander: str = Query(..., min_length=1, description="Primary commander display name"),
|
||||
limit: int = Query(5, ge=1, le=20, description="Maximum suggestions per partner mode"),
|
||||
visible_limit: int = Query(3, ge=0, le=10, description="Number of suggestions to mark as visible"),
|
||||
include_hidden: bool = Query(False, description="When true, include hidden suggestions in the response"),
|
||||
partner: Optional[List[str]] = Query(None, description="Available partner commander names"),
|
||||
background: Optional[List[str]] = Query(None, description="Available background names"),
|
||||
mode: Optional[List[str]] = Query(None, description="Restrict results to specific partner modes"),
|
||||
refresh: bool = Query(False, description="When true, force a dataset refresh before scoring"),
|
||||
):
|
||||
if not (ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS):
|
||||
raise HTTPException(status_code=404, detail="Partner suggestions are disabled")
|
||||
|
||||
commander_name = (commander or "").strip()
|
||||
if not commander_name:
|
||||
raise HTTPException(status_code=400, detail="Commander name is required")
|
||||
|
||||
include_modes = _parse_modes(mode)
|
||||
result = get_partner_suggestions(
|
||||
commander_name,
|
||||
limit_per_mode=limit,
|
||||
include_modes=include_modes or None,
|
||||
refresh_dataset=refresh,
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=503, detail="Partner suggestion dataset is unavailable")
|
||||
|
||||
partner_names = _coerce_name_list(partner)
|
||||
background_names = _coerce_name_list(background)
|
||||
|
||||
# If the client didn't provide select options, fall back to the suggestions themselves.
|
||||
if not partner_names:
|
||||
for key, entries in result.by_mode.items():
|
||||
if key == PartnerMode.BACKGROUND.value:
|
||||
continue
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name_value = entry.get("name")
|
||||
if isinstance(name_value, str) and name_value.strip():
|
||||
partner_names.append(name_value)
|
||||
if not background_names:
|
||||
background_entries = result.by_mode.get(PartnerMode.BACKGROUND.value, [])
|
||||
for entry in background_entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name_value = entry.get("name")
|
||||
if isinstance(name_value, str) and name_value.strip():
|
||||
background_names.append(name_value)
|
||||
|
||||
partner_names = _coerce_name_list(partner_names)
|
||||
background_names = _coerce_name_list(background_names)
|
||||
|
||||
visible, hidden = result.flatten(partner_names, background_names, visible_limit=visible_limit)
|
||||
visible_count = len(visible)
|
||||
hidden_count = len(hidden)
|
||||
if include_hidden:
|
||||
combined_visible = visible + hidden
|
||||
remaining = []
|
||||
else:
|
||||
combined_visible = visible
|
||||
remaining = hidden
|
||||
|
||||
payload = {
|
||||
"commander": {
|
||||
"display_name": result.display_name,
|
||||
"canonical": result.canonical,
|
||||
},
|
||||
"metadata": result.metadata,
|
||||
"modes": result.by_mode,
|
||||
"visible": combined_visible,
|
||||
"hidden": remaining,
|
||||
"total": result.total,
|
||||
"limit": {
|
||||
"per_mode": limit,
|
||||
"visible": visible_limit,
|
||||
},
|
||||
"available_modes": [mode_key for mode_key, entries in result.by_mode.items() if entries],
|
||||
"has_hidden": bool(remaining),
|
||||
}
|
||||
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
try:
|
||||
mode_counts = {mode_key: len(entries) for mode_key, entries in result.by_mode.items()}
|
||||
available_modes = [mode_key for mode_key, count in mode_counts.items() if count]
|
||||
log_partner_suggestions_generated(
|
||||
request,
|
||||
commander_display=result.display_name,
|
||||
commander_canonical=result.canonical,
|
||||
include_modes=[mode.value for mode in include_modes] if include_modes else [],
|
||||
available_modes=available_modes,
|
||||
total=result.total,
|
||||
mode_counts=mode_counts,
|
||||
visible_count=visible_count,
|
||||
hidden_count=hidden_count,
|
||||
limit_per_mode=limit,
|
||||
visible_limit=visible_limit,
|
||||
include_hidden=include_hidden,
|
||||
refresh_requested=refresh,
|
||||
dataset_metadata=result.metadata,
|
||||
)
|
||||
except Exception: # pragma: no cover - telemetry should not break responses
|
||||
pass
|
||||
return JSONResponse(payload, headers=headers)
|
||||
|
|
@ -5,6 +5,7 @@ from fastapi import Request
|
|||
from ..services import owned_store
|
||||
from . import orchestrator as orch
|
||||
from deck_builder import builder_constants as bc
|
||||
from .. import app as app_module
|
||||
|
||||
|
||||
def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, include_locks: bool = True) -> Dict[str, Any]:
|
||||
|
|
@ -21,6 +22,13 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
|
|||
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"partner_enabled": bool(sess.get("partner_enabled") and app_module.ENABLE_PARTNER_MECHANICS),
|
||||
"secondary_commander": sess.get("secondary_commander"),
|
||||
"background": sess.get("background"),
|
||||
"partner_mode": sess.get("partner_mode"),
|
||||
"partner_warnings": list(sess.get("partner_warnings", []) or []),
|
||||
"combined_commander": sess.get("combined_commander"),
|
||||
"partner_auto_note": sess.get("partner_auto_note"),
|
||||
"owned_set": owned_set(),
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
|
|
@ -69,6 +77,9 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
|
|||
use_owned = bool(sess.get("use_owned_only"))
|
||||
prefer = bool(sess.get("prefer_owned"))
|
||||
owned_names_list = owned_names() if (use_owned or prefer) else None
|
||||
partner_enabled = bool(sess.get("partner_enabled")) and app_module.ENABLE_PARTNER_MECHANICS
|
||||
secondary_commander = sess.get("secondary_commander") if partner_enabled else None
|
||||
background_choice = sess.get("background") if partner_enabled else None
|
||||
ctx = orch.start_build_ctx(
|
||||
commander=sess.get("commander"),
|
||||
tags=sess.get("tags", []),
|
||||
|
|
@ -87,9 +98,16 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
|
|||
include_cards=sess.get("include_cards"),
|
||||
exclude_cards=sess.get("exclude_cards"),
|
||||
swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
|
||||
partner_feature_enabled=partner_enabled,
|
||||
secondary_commander=secondary_commander,
|
||||
background_commander=background_choice,
|
||||
)
|
||||
if set_on_session:
|
||||
sess["build_ctx"] = ctx
|
||||
if partner_enabled:
|
||||
ctx["partner_mode"] = sess.get("partner_mode")
|
||||
ctx["combined_commander"] = sess.get("combined_commander")
|
||||
ctx["partner_warnings"] = list(sess.get("partner_warnings", []) or [])
|
||||
return ctx
|
||||
|
||||
|
||||
|
|
@ -109,6 +127,7 @@ def commander_hover_context(
|
|||
commander_name: str | None,
|
||||
deck_tags: Iterable[Any] | None,
|
||||
summary: Dict[str, Any] | None,
|
||||
combined: Any | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
from .summary_utils import format_theme_label, format_theme_list
|
||||
|
|
@ -140,6 +159,13 @@ def commander_hover_context(
|
|||
result.append(label)
|
||||
return result
|
||||
|
||||
combined_info: Dict[str, Any]
|
||||
if isinstance(combined, dict):
|
||||
combined_info = combined
|
||||
else:
|
||||
combined_info = {}
|
||||
has_combined = bool(combined_info)
|
||||
|
||||
deck_theme_sources: list[Any] = []
|
||||
_extend_sources(deck_theme_sources, list(deck_tags or []))
|
||||
meta_info: Dict[str, Any] = {}
|
||||
|
|
@ -176,6 +202,8 @@ def commander_hover_context(
|
|||
_extend_sources(commander_theme_sources, commander_meta.get("tags"))
|
||||
_extend_sources(commander_theme_sources, commander_meta.get("themes"))
|
||||
|
||||
_extend_sources(commander_theme_sources, combined_info.get("theme_tags"))
|
||||
|
||||
commander_theme_tags = format_theme_list(commander_theme_sources)
|
||||
if commander_name and not commander_theme_tags:
|
||||
try:
|
||||
|
|
@ -211,6 +239,36 @@ def commander_hover_context(
|
|||
slug_seen.add(slug)
|
||||
commander_tag_slugs.append(slug)
|
||||
|
||||
raw_color_identity = combined_info.get("color_identity") if combined_info else None
|
||||
commander_color_identity: list[str] = []
|
||||
if isinstance(raw_color_identity, (list, tuple, set)):
|
||||
for item in raw_color_identity:
|
||||
token = str(item).strip().upper()
|
||||
if token:
|
||||
commander_color_identity.append(token)
|
||||
|
||||
commander_color_label = ""
|
||||
if has_combined:
|
||||
commander_color_label = str(combined_info.get("color_label") or "").strip()
|
||||
if not commander_color_label and commander_color_identity:
|
||||
commander_color_label = " / ".join(commander_color_identity)
|
||||
if has_combined and not commander_color_label:
|
||||
commander_color_label = "Colorless (C)"
|
||||
|
||||
commander_color_code = str(combined_info.get("color_code") or "").strip() if has_combined else ""
|
||||
commander_partner_mode = str(combined_info.get("partner_mode") or "").strip() if has_combined else ""
|
||||
commander_secondary_name = str(combined_info.get("secondary_name") or "").strip() if has_combined else ""
|
||||
commander_primary_name = str(combined_info.get("primary_name") or commander_name or "").strip()
|
||||
|
||||
commander_display_name = commander_primary_name
|
||||
if commander_secondary_name:
|
||||
if commander_partner_mode == "background":
|
||||
commander_display_name = f"{commander_primary_name} + Background: {commander_secondary_name}".strip()
|
||||
else:
|
||||
commander_display_name = f"{commander_primary_name} + {commander_secondary_name}".strip()
|
||||
elif not commander_display_name:
|
||||
commander_display_name = str(commander_name or "").strip()
|
||||
|
||||
reason_bits: list[str] = []
|
||||
if deck_theme_tags:
|
||||
reason_bits.append("Deck themes: " + ", ".join(deck_theme_tags))
|
||||
|
|
@ -225,6 +283,13 @@ def commander_hover_context(
|
|||
"commander_overlap_tags": overlap_tags,
|
||||
"commander_reason_text": "; ".join(reason_bits),
|
||||
"commander_role_label": format_theme_label("Commander") if commander_name else "",
|
||||
"commander_color_identity": commander_color_identity,
|
||||
"commander_color_label": commander_color_label,
|
||||
"commander_color_code": commander_color_code,
|
||||
"commander_partner_mode": commander_partner_mode,
|
||||
"commander_secondary_name": commander_secondary_name,
|
||||
"commander_primary_name": commander_primary_name,
|
||||
"commander_display_name": commander_display_name,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -274,8 +339,11 @@ def step5_ctx_from_result(
|
|||
commander_name=ctx.get("commander"),
|
||||
deck_tags=sess.get("tags"),
|
||||
summary=ctx.get("summary") if ctx.get("summary") else res.get("summary"),
|
||||
combined=ctx.get("combined_commander"),
|
||||
)
|
||||
ctx.update(hover_meta)
|
||||
if "commander_display_name" not in ctx or not ctx.get("commander_display_name"):
|
||||
ctx["commander_display_name"] = ctx.get("commander")
|
||||
return ctx
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,12 +24,16 @@ import re
|
|||
from urllib.parse import quote
|
||||
|
||||
from path_util import csv_dir
|
||||
from deck_builder.partner_background_utils import analyze_partner_background
|
||||
|
||||
__all__ = [
|
||||
"CommanderRecord",
|
||||
"CommanderCatalog",
|
||||
"load_commander_catalog",
|
||||
"clear_commander_catalog_cache",
|
||||
"find_commander_record",
|
||||
"normalized_restricted_labels",
|
||||
"shared_restricted_partner_label",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -80,9 +84,13 @@ class CommanderRecord:
|
|||
image_small_url: str
|
||||
image_normal_url: str
|
||||
partner_with: Tuple[str, ...]
|
||||
has_plain_partner: bool
|
||||
is_partner: bool
|
||||
supports_backgrounds: bool
|
||||
is_background: bool
|
||||
is_doctor: bool
|
||||
is_doctors_companion: bool
|
||||
restricted_partner_labels: Tuple[str, ...]
|
||||
search_haystack: str
|
||||
|
||||
|
||||
|
|
@ -104,6 +112,36 @@ class CommanderCatalog:
|
|||
_CACHE: Dict[str, CommanderCatalog] = {}
|
||||
|
||||
|
||||
def normalized_restricted_labels(record: CommanderRecord | object) -> Dict[str, str]:
|
||||
labels: Dict[str, str] = {}
|
||||
raw_labels = getattr(record, "restricted_partner_labels", ()) or ()
|
||||
for label in raw_labels:
|
||||
text = str(label or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
key = text.casefold()
|
||||
if key in labels:
|
||||
continue
|
||||
labels[key] = text
|
||||
return labels
|
||||
|
||||
|
||||
def shared_restricted_partner_label(
|
||||
primary: CommanderRecord | object,
|
||||
candidate: CommanderRecord | object,
|
||||
) -> Optional[str]:
|
||||
primary_labels = normalized_restricted_labels(primary)
|
||||
if not primary_labels:
|
||||
return None
|
||||
candidate_labels = normalized_restricted_labels(candidate)
|
||||
if not candidate_labels:
|
||||
return None
|
||||
for key, display in candidate_labels.items():
|
||||
if key in primary_labels:
|
||||
return display
|
||||
return None
|
||||
|
||||
|
||||
def clear_commander_catalog_cache() -> None:
|
||||
"""Clear the in-memory commander catalog cache (testing/support)."""
|
||||
|
||||
|
|
@ -135,6 +173,31 @@ def load_commander_catalog(
|
|||
return catalog
|
||||
|
||||
|
||||
def find_commander_record(name: str | None) -> CommanderRecord | None:
|
||||
"""Return the first commander record matching the provided name.
|
||||
|
||||
Matching is case-insensitive and considers display name, face name, raw name,
|
||||
and slug variants. Returns ``None`` when the commander cannot be located.
|
||||
"""
|
||||
|
||||
text = _clean_str(name)
|
||||
if not text:
|
||||
return None
|
||||
lowered = text.casefold()
|
||||
slug = _slugify(text)
|
||||
try:
|
||||
catalog = load_commander_catalog()
|
||||
except Exception:
|
||||
return None
|
||||
for record in catalog.entries:
|
||||
for candidate in (record.display_name, record.face_name, record.name):
|
||||
if candidate and candidate.casefold() == lowered:
|
||||
return record
|
||||
if record.slug == slug:
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -220,15 +283,17 @@ def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> Comm
|
|||
theme_tokens = tuple(dict.fromkeys(t.lower() for t in themes if t))
|
||||
edhrec_rank = _parse_int(row.get("edhrecRank"))
|
||||
layout = _clean_str(row.get("layout")) or "normal"
|
||||
partner_with = tuple(_extract_partner_with(oracle_text))
|
||||
is_partner = bool(
|
||||
partner_with
|
||||
or _contains_keyword(oracle_text, "partner")
|
||||
or _contains_keyword(oracle_text, "friends forever")
|
||||
or _contains_keyword(oracle_text, "doctor's companion")
|
||||
)
|
||||
supports_backgrounds = _contains_keyword(oracle_text, "choose a background")
|
||||
is_background = "background" in (type_line.lower() if type_line else "")
|
||||
detection = analyze_partner_background(type_line, oracle_text, raw_themes)
|
||||
partner_with = detection.partner_with
|
||||
if not partner_with:
|
||||
partner_with = tuple(_parse_literal_list(row.get("partnerWith")))
|
||||
has_plain_partner = detection.has_plain_partner
|
||||
is_partner = detection.has_partner
|
||||
supports_backgrounds = detection.choose_background
|
||||
is_background = detection.is_background
|
||||
is_doctor = detection.is_doctor
|
||||
is_doctors_companion = detection.is_doctors_companion
|
||||
restricted_partner_labels = tuple(detection.restricted_partner_labels)
|
||||
|
||||
image_small_url = _build_scryfall_url(display_name, "small")
|
||||
image_normal_url = _build_scryfall_url(display_name, "normal")
|
||||
|
|
@ -261,9 +326,13 @@ def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> Comm
|
|||
image_small_url=image_small_url,
|
||||
image_normal_url=image_normal_url,
|
||||
partner_with=partner_with,
|
||||
has_plain_partner=has_plain_partner,
|
||||
is_partner=is_partner,
|
||||
supports_backgrounds=supports_backgrounds,
|
||||
is_background=is_background,
|
||||
is_doctor=is_doctor,
|
||||
is_doctors_companion=is_doctors_companion,
|
||||
restricted_partner_labels=restricted_partner_labels,
|
||||
search_haystack=search_haystack,
|
||||
)
|
||||
|
||||
|
|
@ -277,7 +346,14 @@ def _clean_str(value: object) -> str:
|
|||
def _clean_multiline(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
||||
text = str(value)
|
||||
if "\\r\\n" in text or "\\n" in text or "\\r" in text:
|
||||
text = (
|
||||
text.replace("\\r\\n", "\n")
|
||||
.replace("\\r", "\n")
|
||||
.replace("\\n", "\n")
|
||||
)
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
return "\n".join(line.rstrip() for line in text.split("\n"))
|
||||
|
||||
|
||||
|
|
@ -334,35 +410,6 @@ def _split_to_list(value: object) -> List[str]:
|
|||
parts = [part.strip() for part in text.split(",")]
|
||||
return [part for part in parts if part]
|
||||
|
||||
|
||||
def _extract_partner_with(text: str) -> List[str]:
|
||||
if not text:
|
||||
return []
|
||||
out: List[str] = []
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
anchor = "Partner with "
|
||||
if anchor not in line:
|
||||
continue
|
||||
after = line.split(anchor, 1)[1]
|
||||
# Remove reminder text in parentheses and trailing punctuation.
|
||||
target = after.split("(", 1)[0]
|
||||
target = target.replace(" and ", ",")
|
||||
for token in target.split(","):
|
||||
cleaned = token.strip().strip(".")
|
||||
if cleaned:
|
||||
out.append(cleaned)
|
||||
return out
|
||||
|
||||
|
||||
def _contains_keyword(text: str, needle: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
return needle.lower() in text.lower()
|
||||
|
||||
|
||||
def _parse_color_identity(value: object) -> Tuple[Tuple[str, ...], bool]:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ from datetime import datetime as _dt
|
|||
import re
|
||||
import unicodedata
|
||||
from glob import glob
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from deck_builder.partner_selection import apply_partner_inputs
|
||||
from exceptions import CommanderPartnerError
|
||||
|
||||
_TAG_ACRONYM_KEEP = {"EDH", "ETB", "ETBs", "CMC", "ET", "OTK"}
|
||||
_REASON_SOURCE_OVERRIDES = {
|
||||
|
|
@ -185,6 +190,93 @@ def _run_theme_metadata_enrichment(out_func=None) -> None:
|
|||
return
|
||||
|
||||
|
||||
def _maybe_refresh_partner_synergy(out_func=None, *, force: bool = False, root: str | os.PathLike[str] | None = None) -> None:
|
||||
"""Generate partner synergy dataset when missing or stale.
|
||||
|
||||
The helper executes the build_partner_suggestions script when the analytics
|
||||
payload is absent or older than its source assets. Failures are logged but do
|
||||
not block the calling workflow.
|
||||
"""
|
||||
try:
|
||||
root_path = Path(root) if root is not None else Path(__file__).resolve().parents[3]
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
script_path = root_path / "code" / "scripts" / "build_partner_suggestions.py"
|
||||
if not script_path.exists():
|
||||
return
|
||||
|
||||
dataset_dir = root_path / "config" / "analytics"
|
||||
output_path = dataset_dir / "partner_synergy.json"
|
||||
|
||||
needs_refresh = force or not output_path.exists()
|
||||
dataset_mtime = 0.0
|
||||
if output_path.exists():
|
||||
try:
|
||||
dataset_mtime = output_path.stat().st_mtime
|
||||
except Exception:
|
||||
dataset_mtime = 0.0
|
||||
|
||||
if not needs_refresh:
|
||||
source_times: list[float] = []
|
||||
candidates = [
|
||||
root_path / "config" / "themes" / "theme_list.json",
|
||||
root_path / "csv_files" / "commander_cards.csv",
|
||||
]
|
||||
for candidate in candidates:
|
||||
try:
|
||||
if candidate.exists():
|
||||
source_times.append(candidate.stat().st_mtime)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
deck_dir = root_path / "deck_files"
|
||||
if deck_dir.is_dir():
|
||||
latest_deck_mtime = 0.0
|
||||
for pattern in ("*.json", "*.csv", "*.txt"):
|
||||
for entry in deck_dir.rglob(pattern):
|
||||
try:
|
||||
mt = entry.stat().st_mtime
|
||||
except Exception:
|
||||
continue
|
||||
if mt > latest_deck_mtime:
|
||||
latest_deck_mtime = mt
|
||||
if latest_deck_mtime:
|
||||
source_times.append(latest_deck_mtime)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
newest_source = max(source_times) if source_times else 0.0
|
||||
if newest_source and dataset_mtime < newest_source:
|
||||
needs_refresh = True
|
||||
|
||||
if not needs_refresh:
|
||||
return
|
||||
|
||||
try:
|
||||
dataset_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cmd = [sys.executable, str(script_path), "--output", str(output_path)]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, cwd=str(root_path))
|
||||
if out_func:
|
||||
try:
|
||||
out_func("Partner suggestions dataset refreshed.")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
if out_func:
|
||||
try:
|
||||
out_func(f"Partner suggestions dataset refresh failed: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _global_prune_disallowed_pool(b: DeckBuilder) -> None:
|
||||
"""Hard-prune disallowed categories from the working pool based on bracket limits.
|
||||
|
||||
|
|
@ -1054,6 +1146,10 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
_run_theme_metadata_enrichment(out_func)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_maybe_refresh_partner_synergy(out_func, force=force)
|
||||
except Exception:
|
||||
pass
|
||||
# Bust theme-related in-memory caches so new catalog reflects immediately
|
||||
try:
|
||||
from .theme_catalog_loader import bust_filter_cache # type: ignore
|
||||
|
|
@ -1722,6 +1818,28 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
|||
"csv": csv_path,
|
||||
"txt": txt_path,
|
||||
}
|
||||
try:
|
||||
commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
commander_meta = {}
|
||||
names = commander_meta.get("commander_names") or []
|
||||
if names:
|
||||
meta["commander_names"] = names
|
||||
combined_payload = commander_meta.get("combined_commander")
|
||||
if combined_payload:
|
||||
meta["combined_commander"] = combined_payload
|
||||
partner_mode = commander_meta.get("partner_mode")
|
||||
if partner_mode:
|
||||
meta["partner_mode"] = partner_mode
|
||||
color_identity = commander_meta.get("color_identity")
|
||||
if color_identity:
|
||||
meta["color_identity"] = color_identity
|
||||
primary_commander = commander_meta.get("primary_commander")
|
||||
if primary_commander:
|
||||
meta["commander"] = primary_commander
|
||||
secondary_commander = commander_meta.get("secondary_commander")
|
||||
if secondary_commander:
|
||||
meta["secondary_commander"] = secondary_commander
|
||||
# Attach custom deck name if provided
|
||||
try:
|
||||
custom_base = getattr(b, 'custom_export_base', None)
|
||||
|
|
@ -1842,6 +1960,111 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
|
|||
return stages
|
||||
|
||||
|
||||
def _apply_combined_commander_to_builder(builder: DeckBuilder, combined: Any) -> None:
|
||||
"""Attach combined commander metadata to the builder."""
|
||||
|
||||
try:
|
||||
builder.combined_commander = combined # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
builder.partner_mode = getattr(combined, "partner_mode", None) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
builder.secondary_commander = getattr(combined, "secondary_name", None) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
builder.combined_color_identity = getattr(combined, "color_identity", None) # type: ignore[attr-defined]
|
||||
builder.combined_theme_tags = getattr(combined, "theme_tags", None) # type: ignore[attr-defined]
|
||||
builder.partner_warnings = getattr(combined, "warnings", None) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
commander_dict = getattr(builder, "commander_dict", None)
|
||||
if isinstance(commander_dict, dict):
|
||||
try:
|
||||
mode = getattr(getattr(combined, "partner_mode", None), "value", None)
|
||||
commander_dict["Partner Mode"] = mode
|
||||
commander_dict["Secondary Commander"] = getattr(combined, "secondary_name", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _add_secondary_commander_card(builder: DeckBuilder, commander_df: Any, combined: Any) -> None:
|
||||
"""Ensure the partnered/background commander is present in the deck library."""
|
||||
|
||||
try:
|
||||
secondary_name = getattr(combined, "secondary_name", None)
|
||||
except Exception:
|
||||
secondary_name = None
|
||||
if not secondary_name:
|
||||
return
|
||||
|
||||
try:
|
||||
display_name = str(secondary_name).strip()
|
||||
except Exception:
|
||||
return
|
||||
if not display_name:
|
||||
return
|
||||
|
||||
try:
|
||||
df = commander_df
|
||||
if df is None:
|
||||
return
|
||||
match = df[df["name"].astype(str).str.casefold() == display_name.casefold()]
|
||||
if match.empty and "faceName" in getattr(df, "columns", []):
|
||||
match = df[df["faceName"].astype(str).str.casefold() == display_name.casefold()]
|
||||
if match.empty:
|
||||
return
|
||||
row = match.iloc[0]
|
||||
except Exception:
|
||||
return
|
||||
|
||||
card_name = str(row.get("name") or display_name).strip()
|
||||
card_type = str(row.get("type") or row.get("type_line") or "")
|
||||
mana_cost = str(row.get("manaCost") or "")
|
||||
mana_value = row.get("manaValue", row.get("cmc"))
|
||||
try:
|
||||
if mana_value in ("", None):
|
||||
mana_value = None
|
||||
else:
|
||||
mana_value = float(mana_value)
|
||||
except Exception:
|
||||
mana_value = None
|
||||
|
||||
raw_creatures = row.get("creatureTypes")
|
||||
if isinstance(raw_creatures, str):
|
||||
creature_types = [part.strip() for part in raw_creatures.split(",") if part.strip()]
|
||||
elif isinstance(raw_creatures, (list, tuple)):
|
||||
creature_types = [str(part).strip() for part in raw_creatures if str(part).strip()]
|
||||
else:
|
||||
creature_types = []
|
||||
|
||||
raw_tags = row.get("themeTags")
|
||||
if isinstance(raw_tags, str):
|
||||
tags = [part.strip() for part in raw_tags.split(",") if part.strip()]
|
||||
elif isinstance(raw_tags, (list, tuple)):
|
||||
tags = [str(part).strip() for part in raw_tags if str(part).strip()]
|
||||
else:
|
||||
tags = []
|
||||
|
||||
try:
|
||||
builder.add_card(
|
||||
card_name=card_name,
|
||||
card_type=card_type,
|
||||
mana_cost=mana_cost,
|
||||
mana_value=mana_value,
|
||||
creature_types=creature_types,
|
||||
tags=tags,
|
||||
is_commander=True,
|
||||
sub_role="Partner",
|
||||
added_by="Partner Mechanics",
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def start_build_ctx(
|
||||
commander: str,
|
||||
tags: List[str],
|
||||
|
|
@ -1861,6 +2084,9 @@ def start_build_ctx(
|
|||
include_cards: List[str] | None = None,
|
||||
exclude_cards: List[str] | None = None,
|
||||
swap_mdfc_basics: bool | None = None,
|
||||
partner_feature_enabled: bool | None = None,
|
||||
secondary_commander: str | None = None,
|
||||
background_commander: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
logs: List[str] = []
|
||||
|
||||
|
|
@ -1882,6 +2108,32 @@ def start_build_ctx(
|
|||
if row.empty:
|
||||
raise ValueError(f"Commander not found: {commander}")
|
||||
b._apply_commander_selection(row.iloc[0])
|
||||
if secondary_commander is not None:
|
||||
secondary_commander = str(secondary_commander).strip()
|
||||
if not secondary_commander:
|
||||
secondary_commander = None
|
||||
if background_commander is not None:
|
||||
background_commander = str(background_commander).strip()
|
||||
if not background_commander:
|
||||
background_commander = None
|
||||
combined_partner = None
|
||||
if partner_feature_enabled and (secondary_commander or background_commander):
|
||||
try:
|
||||
combined_partner = apply_partner_inputs(
|
||||
b,
|
||||
primary_name=str(commander),
|
||||
secondary_name=secondary_commander,
|
||||
background_name=background_commander,
|
||||
feature_enabled=True,
|
||||
)
|
||||
except CommanderPartnerError as exc:
|
||||
out(f"Partner selection error: {exc}")
|
||||
except Exception as exc:
|
||||
out(f"Partner selection failed: {exc}")
|
||||
else:
|
||||
if combined_partner is not None:
|
||||
_apply_combined_commander_to_builder(b, combined_partner)
|
||||
_add_secondary_commander_card(b, df, combined_partner)
|
||||
# Tags (explicit + supplemental applied upstream)
|
||||
b.selected_tags = list(tags or [])
|
||||
b.primary_tag = b.selected_tags[0] if len(b.selected_tags) > 0 else None
|
||||
|
|
@ -2158,6 +2410,28 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
"csv": ctx.get("csv_path"),
|
||||
"txt": ctx.get("txt_path"),
|
||||
}
|
||||
try:
|
||||
commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
commander_meta = {}
|
||||
names = commander_meta.get("commander_names") or []
|
||||
if names:
|
||||
meta["commander_names"] = names
|
||||
combined_payload = commander_meta.get("combined_commander")
|
||||
if combined_payload:
|
||||
meta["combined_commander"] = combined_payload
|
||||
partner_mode = commander_meta.get("partner_mode")
|
||||
if partner_mode:
|
||||
meta["partner_mode"] = partner_mode
|
||||
color_identity = commander_meta.get("color_identity")
|
||||
if color_identity:
|
||||
meta["color_identity"] = color_identity
|
||||
primary_commander = commander_meta.get("primary_commander")
|
||||
if primary_commander:
|
||||
meta["commander"] = primary_commander
|
||||
secondary_commander = commander_meta.get("secondary_commander")
|
||||
if secondary_commander:
|
||||
meta["secondary_commander"] = secondary_commander
|
||||
try:
|
||||
custom_base = getattr(b, 'custom_export_base', None)
|
||||
except Exception:
|
||||
|
|
@ -2961,6 +3235,28 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
"csv": ctx.get("csv_path"),
|
||||
"txt": ctx.get("txt_path"),
|
||||
}
|
||||
try:
|
||||
commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
commander_meta = {}
|
||||
names = commander_meta.get("commander_names") or []
|
||||
if names:
|
||||
meta["commander_names"] = names
|
||||
combined_payload = commander_meta.get("combined_commander")
|
||||
if combined_payload:
|
||||
meta["combined_commander"] = combined_payload
|
||||
partner_mode = commander_meta.get("partner_mode")
|
||||
if partner_mode:
|
||||
meta["partner_mode"] = partner_mode
|
||||
color_identity = commander_meta.get("color_identity")
|
||||
if color_identity:
|
||||
meta["color_identity"] = color_identity
|
||||
primary_commander = commander_meta.get("primary_commander")
|
||||
if primary_commander:
|
||||
meta["commander"] = primary_commander
|
||||
secondary_commander = commander_meta.get("secondary_commander")
|
||||
if secondary_commander:
|
||||
meta["secondary_commander"] = secondary_commander
|
||||
try:
|
||||
custom_base = getattr(b, 'custom_export_base', None)
|
||||
except Exception:
|
||||
|
|
|
|||
595
code/web/services/partner_suggestions.py
Normal file
595
code/web/services/partner_suggestions.py
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
"""Partner suggestion dataset loader and scoring utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Iterable, Mapping, Optional, Sequence
|
||||
|
||||
from code.logging_util import get_logger
|
||||
|
||||
from deck_builder.combined_commander import CombinedCommander, PartnerMode, build_combined_commander
|
||||
from deck_builder.suggestions import (
|
||||
PartnerSuggestionContext,
|
||||
ScoreResult,
|
||||
is_noise_theme,
|
||||
score_partner_candidate,
|
||||
)
|
||||
from deck_builder.color_identity_utils import canon_color_code, color_label_from_code
|
||||
from deck_builder.partner_selection import normalize_lookup_name
|
||||
from exceptions import CommanderPartnerError
|
||||
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
_COLOR_NAME_MAP = {
|
||||
"W": "White",
|
||||
"U": "Blue",
|
||||
"B": "Black",
|
||||
"R": "Red",
|
||||
"G": "Green",
|
||||
"C": "Colorless",
|
||||
}
|
||||
|
||||
_MODE_DISPLAY = {
|
||||
PartnerMode.PARTNER.value: "Partner",
|
||||
PartnerMode.PARTNER_WITH.value: "Partner With",
|
||||
PartnerMode.BACKGROUND.value: "Choose a Background",
|
||||
PartnerMode.DOCTOR_COMPANION.value: "Doctor & Companion",
|
||||
}
|
||||
|
||||
_NOTE_LABELS = {
|
||||
"partner_with_match": "Canonical Partner With pair",
|
||||
"background_compatible": "Ideal background match",
|
||||
"doctor_companion_match": "Doctor ↔ Companion pairing",
|
||||
"shared_partner_keyword": "Both commanders have Partner",
|
||||
"restricted_label_match": "Restricted partner label matches",
|
||||
"observed_pairing": "Popular pairing in exported decks",
|
||||
}
|
||||
|
||||
|
||||
def _to_tuple(values: Iterable[str] | None) -> tuple[str, ...]:
|
||||
if not values:
|
||||
return tuple()
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
token = str(value or "").strip()
|
||||
if not token:
|
||||
continue
|
||||
key = token.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(token)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _normalize(value: str | None) -> str:
|
||||
return normalize_lookup_name(value)
|
||||
|
||||
|
||||
def _color_code(identity: Iterable[str]) -> str:
|
||||
code = canon_color_code(tuple(identity))
|
||||
return code or "C"
|
||||
|
||||
|
||||
def _color_label(identity: Iterable[str]) -> str:
|
||||
return color_label_from_code(_color_code(identity))
|
||||
|
||||
|
||||
def _mode_label(mode: PartnerMode | str | None) -> str:
|
||||
if isinstance(mode, PartnerMode):
|
||||
return _MODE_DISPLAY.get(mode.value, mode.value)
|
||||
if isinstance(mode, str):
|
||||
return _MODE_DISPLAY.get(mode, mode.title())
|
||||
return "Partner Mechanics"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommanderEntry:
|
||||
"""Commander metadata extracted from the partner synergy dataset."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
display_name: str
|
||||
payload: Mapping[str, Any]
|
||||
partner_payload: Mapping[str, Any]
|
||||
color_identity: tuple[str, ...]
|
||||
themes: tuple[str, ...]
|
||||
role_tags: tuple[str, ...]
|
||||
|
||||
def to_source(self) -> SimpleNamespace:
|
||||
partner = self.partner_payload
|
||||
partner_with = _to_tuple(partner.get("partner_with"))
|
||||
supports_backgrounds = bool(partner.get("supports_backgrounds") or partner.get("choose_background"))
|
||||
is_partner = bool(partner.get("has_partner") or partner.get("has_plain_partner"))
|
||||
is_background = bool(partner.get("is_background"))
|
||||
is_doctor = bool(partner.get("is_doctor"))
|
||||
is_companion = bool(partner.get("is_doctors_companion"))
|
||||
restricted_labels = _to_tuple(partner.get("restricted_partner_labels"))
|
||||
return SimpleNamespace(
|
||||
name=self.name,
|
||||
display_name=self.display_name,
|
||||
color_identity=self.color_identity,
|
||||
colors=self.color_identity,
|
||||
themes=self.themes,
|
||||
theme_tags=self.themes,
|
||||
raw_tags=self.themes,
|
||||
partner_with=partner_with,
|
||||
supports_backgrounds=supports_backgrounds,
|
||||
is_partner=is_partner,
|
||||
is_background=is_background,
|
||||
is_doctor=is_doctor,
|
||||
is_doctors_companion=is_companion,
|
||||
restricted_partner_labels=restricted_labels,
|
||||
oracle_text="",
|
||||
type_line="",
|
||||
)
|
||||
|
||||
@property
|
||||
def canonical(self) -> str:
|
||||
return self.key
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PartnerSuggestionResult:
|
||||
"""Structured partner suggestions grouped by mode."""
|
||||
|
||||
commander: str
|
||||
display_name: str
|
||||
canonical: str
|
||||
metadata: Mapping[str, Any]
|
||||
by_mode: Mapping[str, list[dict[str, Any]]]
|
||||
total: int
|
||||
|
||||
def flatten(
|
||||
self,
|
||||
partner_names: Iterable[str],
|
||||
background_names: Iterable[str],
|
||||
*,
|
||||
visible_limit: int = 3,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
partner_allowed = {_normalize(name) for name in partner_names if name}
|
||||
background_allowed = {_normalize(name) for name in background_names if name}
|
||||
ordered_modes = [
|
||||
PartnerMode.PARTNER_WITH.value,
|
||||
PartnerMode.PARTNER.value,
|
||||
PartnerMode.DOCTOR_COMPANION.value,
|
||||
PartnerMode.BACKGROUND.value,
|
||||
]
|
||||
visible: list[dict[str, Any]] = []
|
||||
hidden: list[dict[str, Any]] = []
|
||||
for mode_key in ordered_modes:
|
||||
suggestions = self.by_mode.get(mode_key, [])
|
||||
for suggestion in suggestions:
|
||||
name_key = _normalize(suggestion.get("name"))
|
||||
if mode_key == PartnerMode.BACKGROUND.value:
|
||||
if name_key not in background_allowed:
|
||||
continue
|
||||
else:
|
||||
if name_key not in partner_allowed:
|
||||
continue
|
||||
target = visible if len(visible) < visible_limit else hidden
|
||||
target.append(suggestion)
|
||||
return visible, hidden
|
||||
|
||||
|
||||
class PartnerSuggestionDataset:
|
||||
"""Cached partner synergy dataset accessor."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self._payload: Optional[dict[str, Any]] = None
|
||||
self._metadata: dict[str, Any] = {}
|
||||
self._entries: dict[str, CommanderEntry] = {}
|
||||
self._lookup: dict[str, CommanderEntry] = {}
|
||||
self._pairing_counts: dict[tuple[str, str, str], int] = {}
|
||||
self._context: PartnerSuggestionContext = PartnerSuggestionContext()
|
||||
self._mtime_ns: int = -1
|
||||
|
||||
@property
|
||||
def metadata(self) -> Mapping[str, Any]:
|
||||
return self._metadata
|
||||
|
||||
@property
|
||||
def context(self) -> PartnerSuggestionContext:
|
||||
return self._context
|
||||
|
||||
def ensure_loaded(self, *, force: bool = False) -> None:
|
||||
if not self.path.exists():
|
||||
raise FileNotFoundError(self.path)
|
||||
stat = self.path.stat()
|
||||
if not force and self._payload is not None and stat.st_mtime_ns == self._mtime_ns:
|
||||
return
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8") or "{}")
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("partner synergy dataset is not a JSON object")
|
||||
|
||||
commanders = raw.get("commanders") or {}
|
||||
if not isinstance(commanders, Mapping):
|
||||
raise ValueError("commanders section missing in partner synergy dataset")
|
||||
|
||||
entries: dict[str, CommanderEntry] = {}
|
||||
lookup: dict[str, CommanderEntry] = {}
|
||||
for key, payload in commanders.items():
|
||||
if not isinstance(payload, Mapping):
|
||||
continue
|
||||
display = str(payload.get("display_name") or payload.get("name") or key or "").strip()
|
||||
if not display:
|
||||
continue
|
||||
name = str(payload.get("name") or display)
|
||||
partner_payload = payload.get("partner") or {}
|
||||
if not isinstance(partner_payload, Mapping):
|
||||
partner_payload = {}
|
||||
color_identity = _to_tuple(payload.get("color_identity"))
|
||||
themes = tuple(
|
||||
theme for theme in _to_tuple(payload.get("themes")) if not is_noise_theme(theme)
|
||||
)
|
||||
role_tags = _to_tuple(payload.get("role_tags"))
|
||||
entry = CommanderEntry(
|
||||
key=str(key),
|
||||
name=name,
|
||||
display_name=display,
|
||||
payload=payload,
|
||||
partner_payload=partner_payload,
|
||||
color_identity=color_identity,
|
||||
themes=themes,
|
||||
role_tags=role_tags,
|
||||
)
|
||||
entries[entry.canonical] = entry
|
||||
aliases = {
|
||||
_normalize(entry.canonical),
|
||||
_normalize(entry.display_name),
|
||||
_normalize(entry.name),
|
||||
}
|
||||
for alias in aliases:
|
||||
if alias and alias not in lookup:
|
||||
lookup[alias] = entry
|
||||
|
||||
pairings: dict[tuple[str, str, str], int] = {}
|
||||
pairing_block = raw.get("pairings") or {}
|
||||
records = pairing_block.get("records") if isinstance(pairing_block, Mapping) else None
|
||||
if isinstance(records, Sequence):
|
||||
for record in records:
|
||||
if not isinstance(record, Mapping):
|
||||
continue
|
||||
mode = str(record.get("mode") or "unknown").strip().replace("-", "_")
|
||||
primary_key = _normalize(record.get("primary_canonical") or record.get("primary"))
|
||||
secondary_key = _normalize(record.get("secondary_canonical") or record.get("secondary"))
|
||||
if not mode or not primary_key or not secondary_key:
|
||||
continue
|
||||
try:
|
||||
count = int(record.get("count", 0))
|
||||
except Exception:
|
||||
count = 0
|
||||
if count <= 0:
|
||||
continue
|
||||
pairings[(mode, primary_key, secondary_key)] = count
|
||||
pairings[(mode, secondary_key, primary_key)] = count
|
||||
|
||||
self._payload = raw
|
||||
self._metadata = dict(raw.get("metadata") or {})
|
||||
self._entries = entries
|
||||
self._lookup = lookup
|
||||
self._pairing_counts = pairings
|
||||
self._context = PartnerSuggestionContext.from_dataset(raw)
|
||||
self._mtime_ns = stat.st_mtime_ns
|
||||
|
||||
def lookup(self, name: str) -> Optional[CommanderEntry]:
|
||||
key = _normalize(name)
|
||||
if not key:
|
||||
return None
|
||||
entry = self._lookup.get(key)
|
||||
if entry is not None:
|
||||
return entry
|
||||
return self._entries.get(key)
|
||||
|
||||
def entries(self) -> Iterable[CommanderEntry]:
|
||||
return self._entries.values()
|
||||
|
||||
def pairing_count(self, mode: PartnerMode, primary: CommanderEntry, secondary: CommanderEntry) -> int:
|
||||
return int(self._pairing_counts.get((mode.value, primary.canonical, secondary.canonical), 0))
|
||||
|
||||
def build_combined(
|
||||
self,
|
||||
primary: CommanderEntry,
|
||||
candidate: CommanderEntry,
|
||||
mode: PartnerMode,
|
||||
) -> CombinedCommander:
|
||||
primary_src = primary.to_source()
|
||||
candidate_src = candidate.to_source()
|
||||
return build_combined_commander(primary_src, candidate_src, mode)
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[3]
|
||||
DEFAULT_DATASET_PATH = (ROOT_DIR / "config" / "analytics" / "partner_synergy.json").resolve()
|
||||
_DATASET_ENV_VAR = "PARTNER_SUGGESTIONS_DATASET"
|
||||
|
||||
_ENV_OVERRIDE = os.getenv(_DATASET_ENV_VAR)
|
||||
_DATASET_PATH: Path = Path(_ENV_OVERRIDE).expanduser().resolve() if _ENV_OVERRIDE else DEFAULT_DATASET_PATH
|
||||
_DATASET_CACHE: Optional[PartnerSuggestionDataset] = None
|
||||
_DATASET_LOCK = Lock()
|
||||
_DATASET_REFRESH_ATTEMPTED = False
|
||||
|
||||
|
||||
def configure_dataset_path(path: str | Path | None) -> None:
|
||||
"""Override the dataset path (primarily for tests)."""
|
||||
|
||||
global _DATASET_PATH, _DATASET_CACHE
|
||||
if path is None:
|
||||
_DATASET_PATH = DEFAULT_DATASET_PATH
|
||||
os.environ.pop(_DATASET_ENV_VAR, None)
|
||||
else:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
_DATASET_PATH = resolved
|
||||
os.environ[_DATASET_ENV_VAR] = str(resolved)
|
||||
_DATASET_CACHE = None
|
||||
|
||||
|
||||
def load_dataset(*, force: bool = False, refresh: bool = False) -> Optional[PartnerSuggestionDataset]:
|
||||
"""Return the cached dataset, reloading if needed.
|
||||
|
||||
Args:
|
||||
force: When True, bypass the in-memory cache and reload the dataset from disk.
|
||||
refresh: When True, attempt to regenerate the dataset before loading. This
|
||||
resets the "already tried" guard so manual refresh actions can retry
|
||||
regeneration after an earlier failure.
|
||||
"""
|
||||
|
||||
global _DATASET_CACHE, _DATASET_REFRESH_ATTEMPTED
|
||||
with _DATASET_LOCK:
|
||||
if refresh:
|
||||
_DATASET_REFRESH_ATTEMPTED = False
|
||||
_DATASET_CACHE = None
|
||||
|
||||
dataset = _DATASET_CACHE
|
||||
if dataset is None or force or refresh:
|
||||
dataset = PartnerSuggestionDataset(_DATASET_PATH)
|
||||
try:
|
||||
dataset.ensure_loaded(force=force or refresh or dataset is not _DATASET_CACHE)
|
||||
except FileNotFoundError:
|
||||
LOGGER.debug("partner suggestions dataset missing at %s", _DATASET_PATH)
|
||||
# Attempt to materialize the dataset automatically when using the default path.
|
||||
allow_auto_refresh = (
|
||||
_DATASET_PATH == DEFAULT_DATASET_PATH
|
||||
and (refresh or not _DATASET_REFRESH_ATTEMPTED)
|
||||
)
|
||||
if allow_auto_refresh:
|
||||
_DATASET_REFRESH_ATTEMPTED = True
|
||||
try:
|
||||
from .orchestrator import _maybe_refresh_partner_synergy # type: ignore
|
||||
|
||||
_maybe_refresh_partner_synergy(None, force=True)
|
||||
except Exception as refresh_exc: # pragma: no cover - best-effort
|
||||
LOGGER.debug(
|
||||
"partner suggestions auto-refresh failed: %s",
|
||||
refresh_exc,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
dataset.ensure_loaded(force=True)
|
||||
except FileNotFoundError:
|
||||
LOGGER.debug(
|
||||
"partner suggestions dataset still missing after auto-refresh",
|
||||
exc_info=True,
|
||||
)
|
||||
if refresh:
|
||||
_DATASET_REFRESH_ATTEMPTED = False
|
||||
_DATASET_CACHE = None
|
||||
return None
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
LOGGER.warning("partner suggestions dataset failed after auto-refresh", exc_info=exc)
|
||||
if refresh:
|
||||
_DATASET_REFRESH_ATTEMPTED = False
|
||||
_DATASET_CACHE = None
|
||||
return None
|
||||
else:
|
||||
_DATASET_CACHE = None
|
||||
return None
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
LOGGER.warning("partner suggestions dataset failed to load", exc_info=exc)
|
||||
_DATASET_CACHE = None
|
||||
return None
|
||||
_DATASET_CACHE = dataset
|
||||
return dataset
|
||||
|
||||
|
||||
def _shared_restriction_label(primary: CommanderEntry, candidate: CommanderEntry) -> Optional[str]:
|
||||
primary_labels = set(_to_tuple(primary.partner_payload.get("restricted_partner_labels")))
|
||||
candidate_labels = set(_to_tuple(candidate.partner_payload.get("restricted_partner_labels")))
|
||||
shared = primary_labels & candidate_labels
|
||||
if not shared:
|
||||
return None
|
||||
return sorted(shared, key=str.casefold)[0]
|
||||
|
||||
|
||||
def _color_delta(primary: CommanderEntry, combined: CombinedCommander) -> dict[str, list[str]]:
|
||||
primary_colors = {color.upper() for color in primary.color_identity}
|
||||
combined_colors = {color.upper() for color in combined.color_identity or ()}
|
||||
added = [
|
||||
_COLOR_NAME_MAP.get(color, color)
|
||||
for color in sorted(combined_colors - primary_colors)
|
||||
]
|
||||
removed = [
|
||||
_COLOR_NAME_MAP.get(color, color)
|
||||
for color in sorted(primary_colors - combined_colors)
|
||||
]
|
||||
return {
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
}
|
||||
|
||||
|
||||
def _reason_summary(
|
||||
result: ScoreResult,
|
||||
shared_themes: Sequence[str],
|
||||
pairing_count: int,
|
||||
color_delta: Mapping[str, Sequence[str]],
|
||||
) -> tuple[str, list[str]]:
|
||||
parts: list[str] = []
|
||||
details: list[str] = []
|
||||
score_percent = int(round(max(0.0, min(1.0, result.score)) * 100))
|
||||
parts.append(f"{score_percent}% match")
|
||||
if shared_themes:
|
||||
label = ", ".join(shared_themes[:2])
|
||||
parts.append(f"Shared themes: {label}")
|
||||
if pairing_count > 0:
|
||||
parts.append(f"Seen in {pairing_count} decks")
|
||||
for note in result.notes:
|
||||
label = _NOTE_LABELS.get(note)
|
||||
if label and label not in details:
|
||||
details.append(label)
|
||||
if not details and pairing_count > 0:
|
||||
details.append(f"Observed together {pairing_count} time(s)")
|
||||
added = color_delta.get("added") or []
|
||||
if added:
|
||||
details.append("Adds " + ", ".join(added))
|
||||
overlap_component = float(result.components.get("overlap", 0.0))
|
||||
if overlap_component >= 0.35 and len(parts) < 3:
|
||||
percent = int(round(overlap_component * 100))
|
||||
details.append(f"Theme overlap {percent}%")
|
||||
summary = " • ".join(parts[:3])
|
||||
return summary, details
|
||||
|
||||
|
||||
def _build_suggestion_payload(
|
||||
primary: CommanderEntry,
|
||||
candidate: CommanderEntry,
|
||||
mode: PartnerMode,
|
||||
result: ScoreResult,
|
||||
combined: CombinedCommander,
|
||||
pairing_count: int,
|
||||
) -> dict[str, Any]:
|
||||
shared_themes = sorted(
|
||||
{
|
||||
theme
|
||||
for theme in primary.themes
|
||||
if theme in candidate.themes and not is_noise_theme(theme)
|
||||
},
|
||||
key=str.casefold,
|
||||
)
|
||||
color_delta = _color_delta(primary, combined)
|
||||
summary, details = _reason_summary(result, shared_themes, pairing_count, color_delta)
|
||||
suggestion = {
|
||||
"name": candidate.display_name,
|
||||
"mode": mode.value,
|
||||
"mode_label": _mode_label(mode),
|
||||
"score": max(0.0, min(1.0, float(result.score))),
|
||||
"score_percent": int(round(max(0.0, min(1.0, float(result.score))) * 100)),
|
||||
"score_components": dict(result.components),
|
||||
"notes": list(result.notes),
|
||||
"shared_themes": shared_themes,
|
||||
"candidate_themes": list(candidate.themes),
|
||||
"theme_tags": list(combined.theme_tags or ()),
|
||||
"summary": summary,
|
||||
"reasons": details,
|
||||
"pairing_count": pairing_count,
|
||||
"color_code": combined.color_code or _color_code(combined.color_identity or ()),
|
||||
"color_label": combined.color_label or _color_label(combined.color_identity or ()),
|
||||
"color_identity": list(combined.color_identity or ()),
|
||||
"candidate_colors": list(candidate.color_identity),
|
||||
"primary_colors": list(combined.primary_color_identity or primary.color_identity),
|
||||
"secondary_colors": list(combined.secondary_color_identity or candidate.color_identity),
|
||||
"color_delta": color_delta,
|
||||
"restriction_label": _shared_restriction_label(primary, candidate),
|
||||
}
|
||||
if combined.secondary_name:
|
||||
suggestion["secondary_name"] = combined.secondary_name
|
||||
suggestion["preview"] = {
|
||||
"primary_name": combined.primary_name,
|
||||
"secondary_name": combined.secondary_name,
|
||||
"partner_mode": mode.value,
|
||||
"partner_mode_label": _mode_label(mode),
|
||||
"color_label": suggestion["color_label"],
|
||||
"color_code": suggestion["color_code"],
|
||||
"theme_tags": list(combined.theme_tags or ()),
|
||||
"secondary_role_label": getattr(combined, "secondary_name", None) and (
|
||||
"Background" if mode is PartnerMode.BACKGROUND else (
|
||||
"Doctor's Companion" if mode is PartnerMode.DOCTOR_COMPANION else "Partner commander"
|
||||
)
|
||||
),
|
||||
}
|
||||
return suggestion
|
||||
|
||||
|
||||
def get_partner_suggestions(
|
||||
commander_name: str,
|
||||
*,
|
||||
limit_per_mode: int = 5,
|
||||
include_modes: Optional[Sequence[PartnerMode]] = None,
|
||||
min_score: float = 0.15,
|
||||
refresh_dataset: bool = False,
|
||||
) -> Optional[PartnerSuggestionResult]:
|
||||
dataset = load_dataset(force=refresh_dataset, refresh=refresh_dataset)
|
||||
if dataset is None:
|
||||
return None
|
||||
|
||||
primary_entry = dataset.lookup(commander_name)
|
||||
if primary_entry is None:
|
||||
return PartnerSuggestionResult(
|
||||
commander=commander_name,
|
||||
display_name=commander_name,
|
||||
canonical=_normalize(commander_name) or commander_name,
|
||||
metadata=dataset.metadata,
|
||||
by_mode={},
|
||||
total=0,
|
||||
)
|
||||
|
||||
allowed_modes = set(include_modes) if include_modes else {
|
||||
PartnerMode.PARTNER,
|
||||
PartnerMode.PARTNER_WITH,
|
||||
PartnerMode.BACKGROUND,
|
||||
PartnerMode.DOCTOR_COMPANION,
|
||||
}
|
||||
grouped: dict[str, list[dict[str, Any]]] = {
|
||||
PartnerMode.PARTNER.value: [],
|
||||
PartnerMode.PARTNER_WITH.value: [],
|
||||
PartnerMode.BACKGROUND.value: [],
|
||||
PartnerMode.DOCTOR_COMPANION.value: [],
|
||||
}
|
||||
|
||||
total = 0
|
||||
primary_source = primary_entry.payload
|
||||
context = dataset.context
|
||||
|
||||
for candidate_entry in dataset.entries():
|
||||
if candidate_entry.canonical == primary_entry.canonical:
|
||||
continue
|
||||
try:
|
||||
result = score_partner_candidate(primary_source, candidate_entry.payload, context=context)
|
||||
except Exception: # pragma: no cover - defensive scoring guard
|
||||
continue
|
||||
mode = result.mode
|
||||
if mode is PartnerMode.NONE or mode not in allowed_modes:
|
||||
continue
|
||||
if result.score < min_score:
|
||||
continue
|
||||
try:
|
||||
combined = dataset.build_combined(primary_entry, candidate_entry, mode)
|
||||
except CommanderPartnerError:
|
||||
continue
|
||||
except Exception: # pragma: no cover - defensive
|
||||
continue
|
||||
pairing_count = dataset.pairing_count(mode, primary_entry, candidate_entry)
|
||||
suggestion = _build_suggestion_payload(primary_entry, candidate_entry, mode, result, combined, pairing_count)
|
||||
grouped[mode.value].append(suggestion)
|
||||
total += 1
|
||||
|
||||
for mode_key, suggestions in grouped.items():
|
||||
suggestions.sort(key=lambda item: (-float(item.get("score", 0.0)), item.get("name", "").casefold()))
|
||||
if limit_per_mode > 0:
|
||||
grouped[mode_key] = suggestions[:limit_per_mode]
|
||||
|
||||
return PartnerSuggestionResult(
|
||||
commander=primary_entry.display_name,
|
||||
display_name=primary_entry.display_name,
|
||||
canonical=primary_entry.canonical,
|
||||
metadata=dataset.metadata,
|
||||
by_mode=grouped,
|
||||
total=sum(len(s) for s in grouped.values()),
|
||||
)
|
||||
|
|
@ -2,16 +2,19 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Mapping, Optional, Sequence
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
__all__ = [
|
||||
"log_commander_page_view",
|
||||
"log_commander_create_deck",
|
||||
"log_partner_suggestions_generated",
|
||||
"log_partner_suggestion_selected",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger("web.commander_browser")
|
||||
_PARTNER_LOGGER = logging.getLogger("web.partner_suggestions")
|
||||
|
||||
|
||||
def _emit(logger: logging.Logger, payload: Dict[str, Any]) -> None:
|
||||
|
|
@ -104,3 +107,113 @@ def log_commander_create_deck(
|
|||
"client_ip": _client_ip(request),
|
||||
}
|
||||
_emit(_LOGGER, payload)
|
||||
|
||||
|
||||
def _extract_dataset_metadata(metadata: Mapping[str, Any] | None) -> Dict[str, Any]:
|
||||
if not isinstance(metadata, Mapping):
|
||||
return {}
|
||||
snapshot: Dict[str, Any] = {}
|
||||
for key in ("dataset_version", "generated_at", "record_count", "entry_count", "build_id"):
|
||||
if key in metadata:
|
||||
snapshot[key] = metadata[key]
|
||||
if not snapshot:
|
||||
# Fall back to a small subset to avoid logging the full metadata document.
|
||||
for key, value in list(metadata.items())[:5]:
|
||||
snapshot[key] = value
|
||||
return snapshot
|
||||
|
||||
|
||||
def log_partner_suggestions_generated(
|
||||
request: Request,
|
||||
*,
|
||||
commander_display: str,
|
||||
commander_canonical: str,
|
||||
include_modes: Sequence[str] | None,
|
||||
available_modes: Sequence[str],
|
||||
total: int,
|
||||
mode_counts: Mapping[str, int],
|
||||
visible_count: int,
|
||||
hidden_count: int,
|
||||
limit_per_mode: int,
|
||||
visible_limit: int,
|
||||
include_hidden: bool,
|
||||
refresh_requested: bool,
|
||||
dataset_metadata: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
payload: Dict[str, Any] = {
|
||||
"event": "partner_suggestions.generated",
|
||||
"request_id": _request_id(request),
|
||||
"path": str(request.url.path),
|
||||
"query": _query_snapshot(request),
|
||||
"commander": {
|
||||
"display": commander_display,
|
||||
"canonical": commander_canonical,
|
||||
},
|
||||
"limits": {
|
||||
"per_mode": int(limit_per_mode),
|
||||
"visible": int(visible_limit),
|
||||
"include_hidden": bool(include_hidden),
|
||||
},
|
||||
"result": {
|
||||
"total": int(total),
|
||||
"visible_count": int(visible_count),
|
||||
"hidden_count": int(hidden_count),
|
||||
"available_modes": list(available_modes),
|
||||
"mode_counts": {str(key): int(value) for key, value in mode_counts.items()},
|
||||
"metadata": _extract_dataset_metadata(dataset_metadata),
|
||||
},
|
||||
"filters": {
|
||||
"include_modes": [str(mode) for mode in (include_modes or [])],
|
||||
"refresh": bool(refresh_requested),
|
||||
},
|
||||
"client_ip": _client_ip(request),
|
||||
}
|
||||
_emit(_PARTNER_LOGGER, payload)
|
||||
|
||||
|
||||
def log_partner_suggestion_selected(
|
||||
request: Request,
|
||||
*,
|
||||
commander: str,
|
||||
scope: str | None,
|
||||
partner_enabled: bool,
|
||||
auto_opt_out: bool,
|
||||
auto_assigned: bool,
|
||||
selection_source: Optional[str],
|
||||
secondary_candidate: str | None,
|
||||
background_candidate: str | None,
|
||||
resolved_secondary: str | None,
|
||||
resolved_background: str | None,
|
||||
partner_mode: str | None,
|
||||
has_preview: bool,
|
||||
warnings: Sequence[str] | None,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
payload: Dict[str, Any] = {
|
||||
"event": "partner_suggestions.selected",
|
||||
"request_id": _request_id(request),
|
||||
"path": str(request.url.path),
|
||||
"scope": scope or "",
|
||||
"commander": commander,
|
||||
"partner_enabled": bool(partner_enabled),
|
||||
"auto_opt_out": bool(auto_opt_out),
|
||||
"auto_assigned": bool(auto_assigned),
|
||||
"selection_source": (selection_source or "") or None,
|
||||
"inputs": {
|
||||
"secondary_candidate": secondary_candidate,
|
||||
"background_candidate": background_candidate,
|
||||
},
|
||||
"resolved": {
|
||||
"partner_mode": partner_mode,
|
||||
"secondary": resolved_secondary,
|
||||
"background": resolved_background,
|
||||
},
|
||||
"preview_available": bool(has_preview),
|
||||
"warnings_count": len(warnings or []),
|
||||
"has_error": bool(error),
|
||||
"error": error,
|
||||
"client_ip": _client_ip(request),
|
||||
}
|
||||
if warnings:
|
||||
payload["warnings"] = list(warnings)
|
||||
_emit(_PARTNER_LOGGER, payload)
|
||||
|
|
|
|||
|
|
@ -212,6 +212,18 @@ label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75r
|
|||
select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
|
||||
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
|
||||
small, .muted{ color: var(--muted); }
|
||||
.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; }
|
||||
.partner-preview[hidden]{ display:none !important; }
|
||||
.partner-preview__header{ font-weight:600; }
|
||||
.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; }
|
||||
.partner-preview__art{ flex:0 0 auto; }
|
||||
.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); }
|
||||
.partner-preview__details{ flex:1 1 180px; min-width:0; }
|
||||
.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; }
|
||||
.partner-preview__pairing{ margin-top:.35rem; }
|
||||
.partner-preview__themes{ margin-top:.35rem; font-size:12px; }
|
||||
.partner-preview--static{ margin-bottom:.5rem; }
|
||||
.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); }
|
||||
|
||||
/* Toasts */
|
||||
.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
|
||||
|
|
|
|||
|
|
@ -662,7 +662,7 @@
|
|||
window.__dfcFlipCard = function(card){ if(!card) return; flip(card, card.querySelector('.dfc-toggle')); };
|
||||
window.__dfcGetFace = function(card){ if(!card) return 'front'; return card.getAttribute(FACE_ATTR) || 'front'; };
|
||||
function scan(){
|
||||
document.querySelectorAll('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
|
||||
document.querySelectorAll('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
|
||||
}
|
||||
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; }, { passive:true });
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
|
|
@ -1206,9 +1206,9 @@
|
|||
if(!el) return null;
|
||||
// If inside flip button
|
||||
var btn = el.closest && el.closest('.dfc-toggle');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
|
||||
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
if(container) return container;
|
||||
// Image-based detection (any card image carrying data-card-name)
|
||||
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){
|
||||
|
|
@ -1264,7 +1264,7 @@
|
|||
window.hoverShowByName = function(name){
|
||||
try {
|
||||
var el = document.querySelector('[data-card-name="'+CSS.escape(name)+'"]');
|
||||
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
|
||||
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
|
||||
} catch(_) {}
|
||||
};
|
||||
// Keyboard accessibility & focus traversal (P2 UI Hover keyboard accessibility)
|
||||
|
|
|
|||
|
|
@ -32,12 +32,16 @@
|
|||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Themes</legend>
|
||||
<div id="newdeck-tags-slot" class="muted">
|
||||
<em>Select a commander to see theme recommendations and choices.</em>
|
||||
<input type="hidden" name="primary_tag" />
|
||||
<input type="hidden" name="secondary_tag" />
|
||||
<input type="hidden" name="tertiary_tag" />
|
||||
<input type="hidden" name="tag_mode" value="AND" />
|
||||
<div id="newdeck-tags-slot"{% if not tag_slot_html %} class="muted"{% endif %}>
|
||||
{% if tag_slot_html %}
|
||||
{{ tag_slot_html | safe }}
|
||||
{% else %}
|
||||
<em>Select a commander to see theme recommendations and choices.</em>
|
||||
<input type="hidden" name="primary_tag" />
|
||||
<input type="hidden" name="secondary_tag" />
|
||||
<input type="hidden" name="tertiary_tag" />
|
||||
<input type="hidden" name="tag_mode" value="AND" />
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
|
||||
{% if enable_custom_themes %}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
{% from 'partials/_macros.html' import color_identity %}
|
||||
{% set pname = commander.name %}
|
||||
{% set partner_preview_payload = partner_preview if partner_preview else None %}
|
||||
{% set preview_colors = partner_preview_payload.color_identity if partner_preview_payload else [] %}
|
||||
{% if preview_colors is none %}
|
||||
{% set preview_colors = [] %}
|
||||
{% endif %}
|
||||
{% set preview_label = partner_preview_payload.color_label if partner_preview_payload else '' %}
|
||||
{% if not preview_label and preview_colors %}
|
||||
{% set preview_label = preview_colors|join(' / ') %}
|
||||
{% endif %}
|
||||
{% if not preview_label and partner_preview_payload and (preview_colors|length == 0) %}
|
||||
{% set preview_label = 'Colorless (C)' %}
|
||||
{% endif %}
|
||||
<div id="newdeck-commander-slot" hx-swap-oob="true" style="max-width:230px;">
|
||||
<aside class="card-preview" data-card-name="{{ pname }}" style="max-width: 230px;">
|
||||
<a href="https://scryfall.com/search?q={{ pname|urlencode }}" target="_blank" rel="noopener">
|
||||
|
|
@ -6,6 +19,38 @@
|
|||
</a>
|
||||
</aside>
|
||||
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px;">{{ pname }}</div>
|
||||
{% if partner_preview_payload %}
|
||||
{% set partner_secondary_name = partner_preview_payload.secondary_name %}
|
||||
{% set partner_image_url = partner_preview_payload.secondary_image_url or partner_preview_payload.image_url %}
|
||||
{% if not partner_image_url and partner_secondary_name %}
|
||||
{% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_secondary_name|urlencode ~ '&format=image&version=normal' %}
|
||||
{% endif %}
|
||||
{% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %}
|
||||
{% if not partner_href and partner_secondary_name %}
|
||||
{% set partner_href = 'https://scryfall.com/search?q=' ~ partner_secondary_name|urlencode %}
|
||||
{% endif %}
|
||||
{% if partner_image_url %}
|
||||
<aside class="card-preview partner-card-preview" style="max-width: 230px; margin-top:.75rem;">
|
||||
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
|
||||
<img src="{{ partner_image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" data-card-name="{{ partner_secondary_name or '' }}" style="width:200px; height:auto; display:block; border-radius:6px;" loading="lazy" decoding="async" />
|
||||
{% if partner_href %}</a>{% endif %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
{% if partner_secondary_name %}
|
||||
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px;">
|
||||
{% if partner_preview_payload.secondary_role_label %}<strong>{{ partner_preview_payload.secondary_role_label }}</strong>: {% endif %}{{ partner_secondary_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px; margin-top:.35rem; display:flex; align-items:center; gap:.35rem; flex-wrap:wrap;">
|
||||
{{ color_identity(preview_colors, is_colorless=(preview_colors|length == 0), aria_label=preview_label or '', title_text=preview_label or '') }}
|
||||
<span>{{ preview_label }}</span>
|
||||
</div>
|
||||
{% if partner_preview_payload.theme_tags %}
|
||||
<div class="muted" style="font-size:12px; margin-top:.25rem;">
|
||||
Combined themes: {{ partner_preview_payload.theme_tags|join(', ') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<script>
|
||||
try {
|
||||
var nm = document.querySelector('input[name="name"]');
|
||||
|
|
@ -52,18 +97,18 @@
|
|||
<button type="button" id="modal-reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
|
||||
<span id="modal-tag-count" class="muted" style="font-size:12px;"></span>
|
||||
</div>
|
||||
{% if recommended and recommended|length %}
|
||||
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
|
||||
<div class="muted" style="font-size:12px;">Recommended</div>
|
||||
<div id="modal-tag-reco-block" data-has-reco="{% if recommended and recommended|length %}1{% else %}0{% endif %}" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>
|
||||
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Recommended</div>
|
||||
<div id="modal-tag-reco" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% if recommended and recommended|length %}
|
||||
{% for r in recommended %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
|
||||
<button type="button" class="chip chip-reco" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-tag-reco" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% for r in recommended %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
|
||||
<button type="button" class="chip chip-reco" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% endfor %}
|
||||
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in tags %}
|
||||
<button type="button" class="chip" data-tag="{{ t }}">{{ t }}</button>
|
||||
|
|
@ -83,6 +128,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% set partner_id_prefix = 'modal' %}
|
||||
{% set partner_scope = 'modal' %}
|
||||
{% include "build/_partner_controls.html" %}
|
||||
|
||||
{# Always update the bracket dropdown on commander change; hide 1–2 only when gc_commander is true #}
|
||||
<div id="newdeck-bracket-slot" hx-swap-oob="true">
|
||||
<label>Bracket
|
||||
|
|
@ -102,6 +151,7 @@
|
|||
<script>
|
||||
(function(){
|
||||
var list = document.getElementById('modal-tag-list');
|
||||
var recoBlock = document.getElementById('modal-tag-reco-block');
|
||||
var reco = document.getElementById('modal-tag-reco');
|
||||
var selAll = document.getElementById('modal-reco-select-all');
|
||||
var resetBtn = document.getElementById('modal-reset-tags');
|
||||
|
|
@ -112,6 +162,18 @@
|
|||
var countEl = document.getElementById('modal-tag-count');
|
||||
var selSummary = document.getElementById('modal-selected-themes');
|
||||
if (!list) return;
|
||||
var previewScope = 'modal';
|
||||
function readPartnerPreviewTags(){
|
||||
if (typeof window === 'undefined') return [];
|
||||
var store = window.partnerPreviewState;
|
||||
if (!store) return [];
|
||||
var state = store[previewScope];
|
||||
if (!state) return [];
|
||||
if (Array.isArray(state.theme_tags) && state.theme_tags.length){ return state.theme_tags.slice(); }
|
||||
var payload = state.payload;
|
||||
if (payload && Array.isArray(payload.theme_tags)){ return payload.theme_tags.slice(); }
|
||||
return [];
|
||||
}
|
||||
|
||||
function getSel(){ var a=[]; if(p&&p.value)a.push(p.value); if(s&&s.value)a.push(s.value); if(t&&t.value)a.push(t.value); return a; }
|
||||
function setSel(a){ a = Array.from(new Set(a||[])).filter(Boolean).slice(0,3); if(p) p.value=a[0]||''; if(s) s.value=a[1]||''; if(t) t.value=a[2]||''; updateUI(); }
|
||||
|
|
@ -135,10 +197,78 @@
|
|||
try{ document.dispatchEvent(new CustomEvent('newdeck:tagsChanged')); }catch(_){ }
|
||||
}
|
||||
if (resetBtn) resetBtn.addEventListener('click', function(){ setSel([]); });
|
||||
list.querySelectorAll('button.chip').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); });
|
||||
if (reco){ reco.querySelectorAll('button.chip-reco').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); }); }
|
||||
if (selAll){ selAll.addEventListener('click', function(){ try{ var cur=getSel(); var recs = reco? Array.from(reco.querySelectorAll('button.chip-reco')).map(function(b){return b.dataset.tag||'';}).filter(Boolean):[]; var combined=cur.slice(); recs.forEach(function(x){ if(combined.indexOf(x)===-1) combined.push(x); }); setSel(combined.slice(-3)); }catch(_){} }); }
|
||||
list.addEventListener('click', function(e){
|
||||
var btn = e.target.closest('button.chip');
|
||||
if (!btn || !list.contains(btn)) return;
|
||||
var tag = btn.dataset.tag || '';
|
||||
if (tag){ toggle(tag); }
|
||||
});
|
||||
if (reco){
|
||||
reco.addEventListener('click', function(e){
|
||||
var btn = e.target.closest('button');
|
||||
if (!btn || !reco.contains(btn)) return;
|
||||
if (btn.id === 'modal-reco-select-all'){
|
||||
try {
|
||||
var cur = getSel();
|
||||
var recs = Array.from(reco.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean);
|
||||
var combined = cur.slice();
|
||||
recs.forEach(function(x){ if (combined.indexOf(x) === -1) combined.push(x); });
|
||||
setSel(combined.slice(-3));
|
||||
} catch(_){ }
|
||||
return;
|
||||
}
|
||||
if (btn.classList.contains('chip-reco')){
|
||||
var tag = btn.dataset.tag || '';
|
||||
if (tag){ toggle(tag); }
|
||||
}
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
|
||||
function updatePartnerRecommendations(tags){
|
||||
if (!reco) return;
|
||||
Array.from(reco.querySelectorAll('button.partner-suggestion')).forEach(function(btn){ btn.remove(); });
|
||||
var unique = [];
|
||||
var seen = new Set();
|
||||
(Array.isArray(tags) ? tags : []).forEach(function(tag){
|
||||
var value = String(tag || '').trim();
|
||||
if (!value) return;
|
||||
var key = value.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
unique.push(value);
|
||||
});
|
||||
var insertBefore = selAll && selAll.parentElement === reco ? selAll : null;
|
||||
unique.forEach(function(tag){
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip chip-reco partner-suggestion';
|
||||
btn.dataset.tag = tag;
|
||||
btn.title = 'Synergizes with selected partner pairing';
|
||||
btn.textContent = '★ ' + tag;
|
||||
if (insertBefore){ reco.insertBefore(btn, insertBefore); }
|
||||
else { reco.appendChild(btn); }
|
||||
});
|
||||
var hasAny = reco.querySelectorAll('button.chip-reco').length > 0;
|
||||
if (recoBlock){
|
||||
recoBlock.style.display = hasAny ? '' : 'none';
|
||||
recoBlock.setAttribute('data-has-reco', hasAny ? '1' : '0');
|
||||
}
|
||||
if (selAll){ selAll.style.display = hasAny ? '' : 'none'; }
|
||||
updateUI();
|
||||
}
|
||||
|
||||
document.addEventListener('partner:preview', function(evt){
|
||||
var detail = (evt && evt.detail) || {};
|
||||
if (detail.scope && detail.scope !== previewScope) return;
|
||||
var tags = Array.isArray(detail.theme_tags) && detail.theme_tags.length ? detail.theme_tags : [];
|
||||
if (!tags.length && detail.payload && Array.isArray(detail.payload.theme_tags)){
|
||||
tags = detail.payload.theme_tags;
|
||||
}
|
||||
updatePartnerRecommendations(tags);
|
||||
});
|
||||
|
||||
var initialPartnerTags = readPartnerPreviewTags();
|
||||
updatePartnerRecommendations(initialPartnerTags);
|
||||
updateUI();
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
959
code/web/templates/build/_partner_controls.html
Normal file
959
code/web/templates/build/_partner_controls.html
Normal file
|
|
@ -0,0 +1,959 @@
|
|||
{% set prefix = partner_id_prefix if partner_id_prefix is defined else 'partner' %}
|
||||
{% set feature_available = partner_feature_available if partner_feature_available is defined else False %}
|
||||
{% set partner_capable = partner_capable if partner_capable is defined else False %}
|
||||
{% set partner_options = partner_options if partner_options is defined else [] %}
|
||||
{% set background_options = background_options if background_options is defined else [] %}
|
||||
{% set partner_select_label = partner_select_label if partner_select_label is defined else 'Partner commander' %}
|
||||
{% set partner_select_placeholder = partner_select_placeholder if partner_select_placeholder is defined else 'Select a partner' %}
|
||||
{% set partner_auto_assigned = partner_auto_assigned if partner_auto_assigned is defined else False %}
|
||||
{% set partner_auto_opt_out = partner_auto_opt_out if partner_auto_opt_out is defined else False %}
|
||||
{% set partner_auto_default = partner_auto_default if partner_auto_default is defined else None %}
|
||||
{% set partner_prefill_available = partner_prefill_available if partner_prefill_available is defined else False %}
|
||||
{% set partner_note_id = prefix ~ '-partner-autonote' %}
|
||||
{% set partner_warning_id = prefix ~ '-partner-warnings' %}
|
||||
{% set partner_suggestions_enabled = partner_suggestions_enabled if partner_suggestions_enabled is defined else False %}
|
||||
{% set partner_suggestions = partner_suggestions if partner_suggestions is defined else [] %}
|
||||
{% set partner_suggestions_hidden = partner_suggestions_hidden if partner_suggestions_hidden is defined else [] %}
|
||||
{% set partner_suggestions_total = partner_suggestions_total if partner_suggestions_total is defined else 0 %}
|
||||
{% set partner_suggestions_metadata = partner_suggestions_metadata if partner_suggestions_metadata is defined else {} %}
|
||||
{% set partner_suggestions_loaded = partner_suggestions_loaded if partner_suggestions_loaded is defined else False %}
|
||||
{% set partner_suggestions_error = partner_suggestions_error if partner_suggestions_error is defined else None %}
|
||||
{% set partner_suggestions_available = partner_suggestions_available if partner_suggestions_available is defined else False %}
|
||||
{% set partner_suggestions_has_hidden = partner_suggestions_has_hidden if partner_suggestions_has_hidden is defined else False %}
|
||||
{% if feature_available %}
|
||||
<fieldset>
|
||||
<legend>Partner Mechanics</legend>
|
||||
{% if not partner_capable %}
|
||||
<p class="muted" style="font-size:12px;">This commander doesn't support partner mechanics or backgrounds.</p>
|
||||
{% else %}
|
||||
<input type="hidden" name="partner_enabled" value="{{ partner_hidden_value or '1' }}" />
|
||||
<input type="hidden" name="partner_auto_opt_out" value="{{ '1' if partner_auto_opt_out else '0' }}" data-partner-auto-opt="{{ prefix }}" />
|
||||
<input type="hidden" name="partner_selection_source" value="" data-partner-selection-source="{{ prefix }}" />
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.5rem;">Choose either a partner commander or a background—never both.</div>
|
||||
{% if partner_role_hint %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">{{ partner_role_hint }}</div>
|
||||
{% endif %}
|
||||
{% if primary_partner_with %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">
|
||||
Pairs naturally with <strong>{{ primary_partner_with|join(', ') }}</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if partner_options and partner_options|length and (not background_options or not background_options|length) %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">No Backgrounds available for this commander.</div>
|
||||
{% elif background_options and background_options|length and (not partner_options or not partner_options|length) %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">This commander can't select a partner commander but can choose a Background.</div>
|
||||
{% endif %}
|
||||
{% if partner_error %}
|
||||
<div style="color:#a00; margin-bottom:.5rem; font-weight:600;">{{ partner_error }}</div>
|
||||
{% endif %}
|
||||
<div id="{{ partner_note_id }}" class="partner-autonote" data-partner-autonote="{{ prefix }}" data-autonote="{{ partner_auto_note or '' }}" style="color:#046d1f; margin-bottom:.5rem; font-size:12px;" role="status" aria-live="polite" aria-atomic="true" aria-hidden="{{ 'false' if partner_auto_note else 'true' }}" {% if not partner_auto_note %}hidden{% endif %}>
|
||||
<span class="sr-only">Partner pairing update:</span>
|
||||
<span data-partner-note-copy>{% if partner_auto_note %}{{ partner_auto_note }}{% endif %}</span>
|
||||
</div>
|
||||
{% if partner_prefill_available and partner_auto_default %}
|
||||
<div style="display:flex; align-items:center; gap:.5rem; margin-bottom:.5rem; flex-wrap:wrap;">
|
||||
<button type="button" class="chip{% if not partner_auto_opt_out %} active{% endif %}" data-partner-autotoggle="{{ prefix }}" data-partner-default="{{ partner_auto_default }}" aria-pressed="{% if not partner_auto_opt_out %}true{% else %}false{% endif %}" aria-describedby="{{ partner_note_id }}">
|
||||
{% if partner_auto_opt_out %}Enable default partner{% else %}Use default partner ({{ partner_auto_default }}){% endif %}
|
||||
</button>
|
||||
<span class="muted" style="font-size:12px;">Toggle to opt-out and choose a different partner.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if partner_suggestions_enabled %}
|
||||
<div class="partner-suggestions" data-partner-suggestions="{{ prefix }}" data-partner-scope="{{ partner_scope if partner_scope is defined else prefix }}" data-api-endpoint="{{ partner_suggestions_endpoint if partner_suggestions_endpoint is defined else '/api/partner/suggestions' }}" data-primary-name="{{ primary_commander_display }}" data-suggestions-json='{{ partner_suggestions | tojson }}' data-hidden-json='{{ partner_suggestions_hidden | tojson }}' data-total="{{ partner_suggestions_total }}" data-loaded="{{ '1' if partner_suggestions_loaded else '0' }}" data-error="{{ partner_suggestions_error or '' }}" data-has-hidden="{{ '1' if partner_suggestions_has_hidden else '0' }}" data-available="{{ '1' if partner_suggestions_available else '0' }}" data-metadata-json='{{ partner_suggestions_metadata | tojson }}' style="display:grid; gap:.35rem; margin-bottom:.75rem;">
|
||||
<div class="partner-suggestions__header" style="display:flex; justify-content:space-between; align-items:center; gap:.5rem; flex-wrap:wrap;">
|
||||
<span style="font-weight:600;">Suggested partners</span>
|
||||
<div class="partner-suggestions__controls" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
<button type="button" class="chip" data-partner-suggestions-refresh="{{ prefix }}" aria-label="Refresh partner suggestions">Refresh</button>
|
||||
<button type="button" class="chip" data-partner-suggestions-more="{{ prefix }}" hidden>Show more</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="partner-suggestions__list" data-partner-suggestions-list style="display:flex; flex-wrap:wrap; gap:.35rem;"></div>
|
||||
<div class="partner-suggestions__meta muted" data-partner-suggestions-meta style="font-size:12px;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="partner-controls" data-partner-controls="{{ prefix }}" data-partner-scope="{{ partner_scope if partner_scope is defined else prefix }}" data-primary-name="{{ primary_commander_display }}" style="display:grid; gap:.5rem; margin-bottom:.5rem;">
|
||||
{% if partner_options and partner_options|length %}
|
||||
<label style="display:grid; gap:.35rem;">
|
||||
<span>{{ partner_select_label }}</span>
|
||||
<select name="secondary_commander" id="{{ prefix }}-partner-secondary" data-partner-select="secondary" aria-describedby="{{ partner_note_id }} {{ partner_warning_id }}">
|
||||
<option value="">{{ partner_select_placeholder }}</option>
|
||||
{% for opt in partner_options %}
|
||||
{% set is_selected = (selected_secondary_commander|lower == opt.name|lower) %}
|
||||
<option value="{{ opt.name }}" data-pairing-mode="{{ opt.pairing_mode }}" data-mode-label="{{ opt.mode_label }}" data-color-label="{{ opt.color_label }}" data-color-code="{{ opt.color_code }}" data-image-url="{{ opt.image_url or '' }}" data-scryfall-url="{{ opt.scryfall_url or '' }}" data-role-label="{{ opt.role_label or 'Partner commander' }}" {% if is_selected %}selected{% endif %}>
|
||||
{{ opt.name }} — {{ opt.color_label }}
|
||||
{% if opt.pairing_mode == 'partner_with' %}(Partner With){% elif opt.pairing_mode == 'partner_restricted' and opt.restriction_label %} (Partner - {{ opt.restriction_label }}){% elif opt.pairing_mode == 'doctor_companion' and opt.role_label %} ({{ opt.role_label }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if background_options and background_options|length %}
|
||||
<label style="display:grid; gap:.35rem;">
|
||||
<span>Background</span>
|
||||
<select name="background" id="{{ prefix }}-partner-background" data-partner-select="background" aria-describedby="{{ partner_note_id }} {{ partner_warning_id }}">
|
||||
<option value="">Select a background</option>
|
||||
{% for opt in background_options %}
|
||||
<option value="{{ opt.name }}" data-color-label="{{ opt.color_label }}" data-color-code="{{ opt.color_code if opt.color_code is defined else '' }}" data-image-url="{{ opt.image_url or '' }}" data-scryfall-url="{{ opt.scryfall_url or '' }}" data-role-label="{{ opt.role_label or 'Background' }}" {% if selected_background == opt.name %}selected{% endif %}>{{ opt.name }} — {{ opt.color_label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex; gap:.5rem; margin-bottom:.5rem; flex-wrap:wrap;">
|
||||
<button type="button" class="chip" data-partner-clear="{{ prefix }}">Clear selection</button>
|
||||
</div>
|
||||
<div class="partner-preview" data-partner-preview="{{ prefix }}" {% if partner_preview %}data-preview-json='{{ partner_preview | tojson }}'{% else %}hidden{% endif %}>
|
||||
{% if partner_preview %}
|
||||
{% set preview_image = partner_preview.secondary_image_url or partner_preview.image_url %}
|
||||
{% if not preview_image and partner_preview.secondary_name %}
|
||||
{% set preview_image = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_preview.secondary_name|urlencode ~ '&format=image&version=normal' %}
|
||||
{% endif %}
|
||||
{% set preview_href = partner_preview.secondary_scryfall_url or partner_preview.scryfall_url %}
|
||||
{% if not preview_href and partner_preview.secondary_name %}
|
||||
{% set preview_href = 'https://scryfall.com/search?q=' ~ partner_preview.secondary_name|urlencode %}
|
||||
{% endif %}
|
||||
{% set preview_role = partner_preview.secondary_role_label or partner_preview.role_label %}
|
||||
{% set preview_primary = partner_preview.primary_name or primary_commander_display %}
|
||||
{% set preview_secondary = partner_preview.secondary_name %}
|
||||
{% set preview_themes = partner_preview.theme_tags %}
|
||||
{% set preview_mode_label = partner_preview.partner_mode_label %}
|
||||
{% set preview_color_label = partner_preview.color_label %}
|
||||
<div class="partner-preview__layout">
|
||||
{% if preview_image %}
|
||||
<div class="partner-preview__art">
|
||||
{% if preview_href %}<a href="{{ preview_href }}" target="_blank" rel="noopener">{% endif %}
|
||||
<img src="{{ preview_image }}" alt="{{ (preview_secondary or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" />
|
||||
{% if preview_href %}</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="partner-preview__details">
|
||||
<div class="partner-preview__header">{{ preview_mode_label }}{% if preview_color_label %} • {{ preview_color_label }}{% endif %}</div>
|
||||
{% if preview_role %}
|
||||
<div class="partner-preview__role">{{ preview_role }}</div>
|
||||
{% endif %}
|
||||
{% if preview_secondary %}
|
||||
<div class="partner-preview__pairing">Pairing: {{ preview_primary }}{% if preview_secondary %} + {{ preview_secondary }}{% endif %}</div>
|
||||
{% endif %}
|
||||
{% if preview_themes %}
|
||||
<div class="partner-preview__themes muted">Theme emphasis: {{ preview_themes|join(', ') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="{{ partner_warning_id }}" data-partner-warnings="{{ prefix }}" data-warnings-json='{{ (partner_warnings or []) | tojson }}' style="background:#fff7e5; border:1px solid #f0c36d; border-radius:8px; padding:.75rem; font-size:12px; color:#7a4b02;" role="alert" aria-live="polite" aria-hidden="{{ 'false' if partner_warnings and partner_warnings|length else 'true' }}" {% if not (partner_warnings and partner_warnings|length) %}hidden{% endif %}>
|
||||
{% if partner_warnings and partner_warnings|length %}
|
||||
<strong>Warnings</strong>
|
||||
<ul style="margin:.35rem 0 0 1.1rem;">
|
||||
{% for warn in partner_warnings %}
|
||||
<li>{{ warn }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<script>
|
||||
(function(){
|
||||
var prefix = '{{ prefix }}';
|
||||
var controls = document.querySelector('[data-partner-controls="' + prefix + '"]');
|
||||
if (!controls || controls.dataset.partnerInit === '1') return;
|
||||
controls.dataset.partnerInit = '1';
|
||||
var scope = controls.getAttribute('data-partner-scope') || prefix;
|
||||
var selects = Array.prototype.slice.call(controls.querySelectorAll('[data-partner-select]'));
|
||||
var clearBtn = document.querySelector('[data-partner-clear="' + prefix + '"]');
|
||||
var optInput = document.querySelector('input[name="partner_auto_opt_out"][data-partner-auto-opt="' + prefix + '"]');
|
||||
var autoToggle = document.querySelector('[data-partner-autotoggle="' + prefix + '"]');
|
||||
var defaultPartner = autoToggle ? autoToggle.getAttribute('data-partner-default') : null;
|
||||
var previewBox = document.querySelector('[data-partner-preview="' + prefix + '"]');
|
||||
var warningsBox = document.querySelector('[data-partner-warnings="' + prefix + '"]');
|
||||
var autoNoteBox = document.querySelector('[data-partner-autonote="' + prefix + '"]');
|
||||
var autoNoteCopy = autoNoteBox ? autoNoteBox.querySelector('[data-partner-note-copy]') : null;
|
||||
var primaryName = controls.getAttribute('data-primary-name') || '';
|
||||
var fieldset = controls.closest('fieldset');
|
||||
var partnerEnabledInput = fieldset ? fieldset.querySelector('input[name="partner_enabled"]') : null;
|
||||
var selectionSourceInput = fieldset ? fieldset.querySelector('input[name="partner_selection_source"][data-partner-selection-source="' + prefix + '"]') : null;
|
||||
var initialAutoNote = autoNoteBox ? (autoNoteBox.getAttribute('data-autonote') || '') : '';
|
||||
function setSelectionSource(value){
|
||||
if (!selectionSourceInput) return;
|
||||
if (value && typeof value === 'string'){
|
||||
selectionSourceInput.value = value;
|
||||
} else {
|
||||
selectionSourceInput.value = '';
|
||||
}
|
||||
}
|
||||
function updateSuggestionsMeta(){
|
||||
if (!suggestionsMeta || !suggestionsState){ return; }
|
||||
var message = '';
|
||||
var isError = false;
|
||||
if (suggestionsState.loading){
|
||||
message = 'Loading partner suggestions…';
|
||||
} else if (suggestionsState.error){
|
||||
message = suggestionsState.error;
|
||||
isError = true;
|
||||
} else if (suggestionsState.visible && suggestionsState.visible.length){
|
||||
var shown = suggestionsState.visible.length;
|
||||
if (suggestionsState.expanded && Array.isArray(suggestionsState.hidden)){
|
||||
shown += suggestionsState.hidden.length;
|
||||
}
|
||||
if (suggestionsState.total && suggestionsState.total > 0){
|
||||
message = 'Showing ' + shown + ' of ' + suggestionsState.total + ' suggestions.';
|
||||
} else {
|
||||
message = 'Suggestions generated from recent deck data.';
|
||||
}
|
||||
var meta = suggestionsState.metadata || {};
|
||||
if (meta.generated_at){
|
||||
message += ' Updated ' + meta.generated_at + '.';
|
||||
}
|
||||
} else if (suggestionsState.loaded){
|
||||
message = 'No partner suggestions available for this commander yet.';
|
||||
} else {
|
||||
message = '';
|
||||
}
|
||||
suggestionsMeta.textContent = message;
|
||||
suggestionsMeta.hidden = !message;
|
||||
if (isError){
|
||||
suggestionsMeta.style.color = '#a00';
|
||||
} else {
|
||||
suggestionsMeta.style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
function markSuggestionActive(){
|
||||
if (!suggestionsList || !suggestionsState){ return; }
|
||||
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
|
||||
var bgSel = controls.querySelector('[data-partner-select="background"]');
|
||||
var partnerValue = partnerSel && partnerSel.value ? partnerSel.value.toLowerCase() : '';
|
||||
var backgroundValue = bgSel && bgSel.value ? bgSel.value.toLowerCase() : '';
|
||||
var buttons = suggestionsList.querySelectorAll('[data-partner-suggestion]');
|
||||
buttons.forEach(function(btn){
|
||||
var mode = (btn.getAttribute('data-mode') || 'partner').toLowerCase();
|
||||
var name = (btn.getAttribute('data-name') || '').toLowerCase();
|
||||
var active = false;
|
||||
if (mode === 'background'){
|
||||
active = !!backgroundValue && backgroundValue === name;
|
||||
} else {
|
||||
active = !!partnerValue && partnerValue === name;
|
||||
}
|
||||
btn.classList.toggle('active', active);
|
||||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function renderSuggestions(){
|
||||
if (!suggestionsBox || !suggestionsList || !suggestionsState){
|
||||
return;
|
||||
}
|
||||
suggestionsList.innerHTML = '';
|
||||
if (suggestionsState.error){
|
||||
updateSuggestionsMeta();
|
||||
if (suggestionsMoreButton){ suggestionsMoreButton.hidden = true; }
|
||||
return;
|
||||
}
|
||||
var items = Array.isArray(suggestionsState.visible) ? suggestionsState.visible.slice() : [];
|
||||
if (suggestionsState.expanded && Array.isArray(suggestionsState.hidden)){
|
||||
items = items.concat(suggestionsState.hidden);
|
||||
}
|
||||
if (!items.length){
|
||||
updateSuggestionsMeta();
|
||||
if (suggestionsMoreButton){ suggestionsMoreButton.hidden = true; }
|
||||
return;
|
||||
}
|
||||
items.forEach(function(item){
|
||||
if (!item || !item.name) return;
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip partner-suggestion-chip';
|
||||
btn.style.display = 'flex';
|
||||
btn.style.flexDirection = 'column';
|
||||
btn.style.alignItems = 'flex-start';
|
||||
btn.style.gap = '2px';
|
||||
btn.setAttribute('data-partner-suggestion', '1');
|
||||
btn.setAttribute('data-mode', item.mode || 'partner');
|
||||
btn.setAttribute('data-name', item.name);
|
||||
if (item.mode_label){ btn.setAttribute('data-mode-label', item.mode_label); }
|
||||
if (item.summary){ btn.setAttribute('data-summary', item.summary); }
|
||||
if (typeof item.score_percent === 'number'){ btn.setAttribute('data-score', String(item.score_percent)); }
|
||||
var titleParts = [];
|
||||
if (item.summary){ titleParts.push(item.summary); }
|
||||
if (Array.isArray(item.reasons) && item.reasons.length){ titleParts = titleParts.concat(item.reasons); }
|
||||
if (titleParts.length){ btn.title = titleParts.join(' • '); }
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'partner-suggestion-chip__name';
|
||||
nameSpan.textContent = item.name;
|
||||
nameSpan.style.fontWeight = '600';
|
||||
btn.appendChild(nameSpan);
|
||||
var summaryText = '';
|
||||
if (item.summary){ summaryText = item.summary; }
|
||||
else if (typeof item.score_percent === 'number'){ summaryText = item.score_percent + '% match'; }
|
||||
else if (item.mode_label){ summaryText = item.mode_label; }
|
||||
if (summaryText){
|
||||
var summarySpan = document.createElement('span');
|
||||
summarySpan.className = 'partner-suggestion-chip__meta muted';
|
||||
summarySpan.textContent = summaryText;
|
||||
summarySpan.style.fontSize = '11px';
|
||||
summarySpan.style.opacity = '0.85';
|
||||
btn.appendChild(summarySpan);
|
||||
}
|
||||
suggestionsList.appendChild(btn);
|
||||
});
|
||||
if (suggestionsMoreButton){
|
||||
if (!suggestionsState.expanded && Array.isArray(suggestionsState.hidden) && suggestionsState.hidden.length){
|
||||
suggestionsMoreButton.hidden = false;
|
||||
suggestionsMoreButton.textContent = 'Show more (' + suggestionsState.hidden.length + ')';
|
||||
} else {
|
||||
suggestionsMoreButton.hidden = true;
|
||||
}
|
||||
}
|
||||
markSuggestionActive();
|
||||
updateSuggestionsMeta();
|
||||
}
|
||||
|
||||
function revealHiddenSuggestions(){
|
||||
if (!suggestionsState){ return; }
|
||||
if (Array.isArray(suggestionsState.hidden) && suggestionsState.hidden.length){
|
||||
suggestionsState.expanded = true;
|
||||
renderSuggestions();
|
||||
} else {
|
||||
fetchSuggestions({ includeHidden: true });
|
||||
}
|
||||
}
|
||||
|
||||
function collectSelectNames(kind){
|
||||
var selector = '[data-partner-select="' + kind + '"]';
|
||||
var sel = controls.querySelector(selector);
|
||||
if (!sel){ return []; }
|
||||
var values = [];
|
||||
Array.prototype.forEach.call(sel.options, function(opt){
|
||||
if (!opt || !opt.value){ return; }
|
||||
values.push(opt.value);
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function setSuggestionsLoading(flag){
|
||||
if (!suggestionsState){ return; }
|
||||
suggestionsState.loading = !!flag;
|
||||
if (suggestionsRefreshButton){
|
||||
if (flag){
|
||||
suggestionsRefreshButton.classList.add('loading');
|
||||
suggestionsRefreshButton.setAttribute('aria-busy', 'true');
|
||||
} else {
|
||||
suggestionsRefreshButton.classList.remove('loading');
|
||||
suggestionsRefreshButton.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
updateSuggestionsMeta();
|
||||
}
|
||||
|
||||
function fetchSuggestions(options){
|
||||
if (!suggestionsBox || !suggestionsState){ return; }
|
||||
if (typeof window === 'undefined' || typeof window.fetch !== 'function'){ return; }
|
||||
if (!primaryName){ return; }
|
||||
var includeHidden = !!(options && options.includeHidden);
|
||||
try {
|
||||
var endpoint = suggestionsBox.getAttribute('data-api-endpoint') || '/api/partner/suggestions';
|
||||
var params = new URLSearchParams();
|
||||
params.set('commander', primaryName);
|
||||
var partnerNames = collectSelectNames('secondary');
|
||||
var backgroundNames = collectSelectNames('background');
|
||||
partnerNames.forEach(function(name){ params.append('partner', name); });
|
||||
backgroundNames.forEach(function(name){ params.append('background', name); });
|
||||
params.set('limit', '8');
|
||||
params.set('visible_limit', '3');
|
||||
var modeSet = {};
|
||||
var modes = (options && Array.isArray(options.modes)) ? options.modes : ['partner_with', 'partner', 'doctor_companion', 'background'];
|
||||
modes.forEach(function(mode){
|
||||
var normalized = String(mode || '').trim();
|
||||
if (!normalized){ return; }
|
||||
var lower = normalized.toLowerCase();
|
||||
if (modeSet[lower]){ return; }
|
||||
modeSet[lower] = true;
|
||||
params.append('mode', normalized);
|
||||
});
|
||||
if (includeHidden){ params.set('include_hidden', '1'); }
|
||||
if (options && options.forceRefresh){ params.set('refresh', '1'); }
|
||||
var fetchUrl = endpoint + (endpoint.indexOf('?') === -1 ? '?' : '&') + params.toString();
|
||||
if (suggestionsAbort){
|
||||
try { suggestionsAbort.abort(); } catch(_){ }
|
||||
}
|
||||
suggestionsAbort = new AbortController();
|
||||
setSuggestionsLoading(true);
|
||||
fetch(fetchUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: suggestionsAbort.signal,
|
||||
}).then(function(resp){
|
||||
if (!resp.ok){
|
||||
throw new Error('suggestions request failed');
|
||||
}
|
||||
return resp.json();
|
||||
}).then(function(data){
|
||||
suggestionsAbort = null;
|
||||
suggestionsState.error = '';
|
||||
suggestionsState.loaded = true;
|
||||
suggestionsState.metadata = data && data.metadata ? data.metadata : {};
|
||||
suggestionsState.total = (data && typeof data.total === 'number') ? data.total : 0;
|
||||
suggestionsState.visible = Array.isArray(data && data.visible) ? data.visible : [];
|
||||
suggestionsState.hidden = Array.isArray(data && data.hidden) ? data.hidden : [];
|
||||
suggestionsState.expanded = includeHidden && suggestionsState.hidden.length ? true : false;
|
||||
suggestionsBox.setAttribute('data-loaded', '1');
|
||||
suggestionsBox.setAttribute('data-error', '');
|
||||
renderSuggestions();
|
||||
}).catch(function(err){
|
||||
if (err && err.name === 'AbortError'){ return; }
|
||||
suggestionsState.error = 'Unable to load partner suggestions.';
|
||||
suggestionsBox.setAttribute('data-error', suggestionsState.error);
|
||||
renderSuggestions();
|
||||
}).finally(function(){
|
||||
setSuggestionsLoading(false);
|
||||
});
|
||||
} catch(_err){
|
||||
suggestionsState.error = 'Unable to load partner suggestions.';
|
||||
renderSuggestions();
|
||||
}
|
||||
}
|
||||
var initialWarnings = [];
|
||||
if (warningsBox && warningsBox.dataset.warningsJson){
|
||||
try { initialWarnings = JSON.parse(warningsBox.dataset.warningsJson); }
|
||||
catch(_){ initialWarnings = []; }
|
||||
}
|
||||
var serverPayload = null;
|
||||
if (previewBox && previewBox.dataset.previewJson){
|
||||
try{ serverPayload = JSON.parse(previewBox.dataset.previewJson); }
|
||||
catch(_){ serverPayload = null; }
|
||||
}
|
||||
setServerPayload(serverPayload);
|
||||
var suggestionsBox = document.querySelector('[data-partner-suggestions="' + prefix + '"]');
|
||||
var suggestionsList = null;
|
||||
var suggestionsMeta = null;
|
||||
var suggestionsMoreButton = null;
|
||||
var suggestionsRefreshButton = null;
|
||||
var suggestionsAbort = null;
|
||||
var suggestionsState = null;
|
||||
function parseSuggestionsAttr(element, attr, fallback){
|
||||
if (!element){ return fallback; }
|
||||
var raw = element.getAttribute(attr);
|
||||
if (!raw){ return fallback; }
|
||||
try { return JSON.parse(raw); }
|
||||
catch(_){ return fallback; }
|
||||
}
|
||||
if (suggestionsBox){
|
||||
suggestionsList = suggestionsBox.querySelector('[data-partner-suggestions-list]');
|
||||
suggestionsMeta = suggestionsBox.querySelector('[data-partner-suggestions-meta]');
|
||||
suggestionsMoreButton = suggestionsBox.querySelector('[data-partner-suggestions-more="' + prefix + '"]');
|
||||
suggestionsRefreshButton = suggestionsBox.querySelector('[data-partner-suggestions-refresh="' + prefix + '"]');
|
||||
suggestionsState = {
|
||||
visible: parseSuggestionsAttr(suggestionsBox, 'data-suggestions-json', []),
|
||||
hidden: parseSuggestionsAttr(suggestionsBox, 'data-hidden-json', []),
|
||||
metadata: parseSuggestionsAttr(suggestionsBox, 'data-metadata-json', {}),
|
||||
total: parseInt(suggestionsBox.getAttribute('data-total') || '0', 10) || 0,
|
||||
error: suggestionsBox.getAttribute('data-error') || '',
|
||||
loaded: suggestionsBox.getAttribute('data-loaded') === '1',
|
||||
expanded: false,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
var modeLabels = {
|
||||
'partner': 'Partner',
|
||||
'partner_with': 'Partner With',
|
||||
'doctor_companion': "Doctor & Companion",
|
||||
'background': 'Choose a Background'
|
||||
};
|
||||
function buildCardImageUrl(name){
|
||||
if (!name) return '';
|
||||
return 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal';
|
||||
}
|
||||
function buildScryfallUrl(name){
|
||||
if (!name) return '';
|
||||
return 'https://scryfall.com/search?q=' + encodeURIComponent(name);
|
||||
}
|
||||
function defaultRoleForMode(mode){
|
||||
if (!mode) return '';
|
||||
switch(String(mode).toLowerCase()){
|
||||
case 'background':
|
||||
return 'Background';
|
||||
case 'doctor_companion':
|
||||
return "Doctor pairing";
|
||||
default:
|
||||
return 'Partner commander';
|
||||
}
|
||||
}
|
||||
var previewAbort = null;
|
||||
if (typeof window !== 'undefined' && !window.partnerPreviewState){
|
||||
try { window.partnerPreviewState = {}; } catch(_){ }
|
||||
}
|
||||
|
||||
function setPreviewState(detail){
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!detail || typeof detail !== 'object') return;
|
||||
var scopeKey = detail.scope || scope || prefix;
|
||||
if (!scopeKey) return;
|
||||
var store = window.partnerPreviewState || {};
|
||||
store[scopeKey] = {
|
||||
theme_tags: Array.isArray(detail.theme_tags) ? detail.theme_tags.slice() : [],
|
||||
payload: detail.payload || null,
|
||||
warnings: Array.isArray(detail.warnings) ? detail.warnings.slice() : [],
|
||||
auto_note: detail.auto_note || null,
|
||||
partner_mode: detail.partner_mode || null,
|
||||
resolved_secondary: detail.resolved_secondary || null,
|
||||
resolved_background: detail.resolved_background || null,
|
||||
secondary_role_label: detail.secondary_role_label || detail.role_label || null,
|
||||
};
|
||||
try { window.partnerPreviewState = store; } catch(_){ }
|
||||
}
|
||||
|
||||
function escapeHtml(str){
|
||||
return String(str || "").replace(/[&<>"']/g, function(ch){
|
||||
return ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]);
|
||||
});
|
||||
}
|
||||
|
||||
function clearPreview(){
|
||||
if (!previewBox) return;
|
||||
previewBox.hidden = true;
|
||||
previewBox.innerHTML = '';
|
||||
markSuggestionActive();
|
||||
}
|
||||
|
||||
function renderPreview(payload){
|
||||
if (!previewBox) return;
|
||||
if (!payload){
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
var mode = payload.partner_mode || payload.mode || '';
|
||||
var modeLabel = payload.partner_mode_label || payload.mode_label || modeLabels[mode] || 'Partner Mechanics';
|
||||
var colorLabel = payload.color_label || '';
|
||||
var secondaryName = payload.secondary_name || payload.name || '';
|
||||
var primary = payload.primary_name || primaryName;
|
||||
var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags : [];
|
||||
var imageUrl = payload.secondary_image_url || payload.image_url || '';
|
||||
if (!imageUrl && secondaryName){
|
||||
imageUrl = buildCardImageUrl(secondaryName);
|
||||
}
|
||||
var scryfallUrl = payload.secondary_scryfall_url || payload.scryfall_url || '';
|
||||
if (!scryfallUrl && secondaryName){
|
||||
scryfallUrl = buildScryfallUrl(secondaryName);
|
||||
}
|
||||
var roleLabel = payload.secondary_role_label || payload.role_label || defaultRoleForMode(mode);
|
||||
var html = '<div class="partner-preview__layout">';
|
||||
var normalizedTags = Array.isArray(themes) ? themes.filter(function(tag){ return tag && String(tag).trim(); }).map(function(tag){ return String(tag).trim(); }) : [];
|
||||
themes = normalizedTags;
|
||||
var tagString = normalizedTags.length ? normalizedTags.join(', ') : '';
|
||||
if (imageUrl){
|
||||
var attrParts = [];
|
||||
if (secondaryName){
|
||||
attrParts.push('data-card-name="' + escapeHtml(secondaryName) + '"');
|
||||
attrParts.push('data-original-name="' + escapeHtml(secondaryName) + '"');
|
||||
}
|
||||
if (roleLabel){
|
||||
attrParts.push('data-role="' + escapeHtml(roleLabel) + '"');
|
||||
}
|
||||
if (tagString){
|
||||
attrParts.push('data-tags="' + escapeHtml(tagString) + '"');
|
||||
attrParts.push('data-overlaps="' + escapeHtml(tagString) + '"');
|
||||
}
|
||||
html += '<div class="partner-preview__art card-preview"' + (attrParts.length ? ' ' + attrParts.join(' ') : '') + '>';
|
||||
if (scryfallUrl){
|
||||
html += '<a href="' + escapeHtml(scryfallUrl) + '" target="_blank" rel="noopener">';
|
||||
}
|
||||
html += '<img src="' + escapeHtml(imageUrl) + '" alt="' + escapeHtml((secondaryName || 'Selected card') + ' card image') + '" loading="lazy" decoding="async" data-card-name="' + escapeHtml(secondaryName || '') + '"';
|
||||
if (roleLabel){ html += ' data-role="' + escapeHtml(roleLabel) + '"'; }
|
||||
if (tagString){ html += ' data-tags="' + escapeHtml(tagString) + '" data-overlaps="' + escapeHtml(tagString) + '"'; }
|
||||
html += ' />';
|
||||
if (scryfallUrl){
|
||||
html += '</a>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<div class="partner-preview__details">';
|
||||
html += '<div class="partner-preview__header">' + escapeHtml(modeLabel);
|
||||
if (colorLabel){ html += ' • ' + escapeHtml(colorLabel); }
|
||||
html += '</div>';
|
||||
if (roleLabel){
|
||||
html += '<div class="partner-preview__role">' + escapeHtml(roleLabel) + '</div>';
|
||||
}
|
||||
if (secondaryName){
|
||||
var pairing = escapeHtml(primary);
|
||||
if (pairing){ pairing += ' + '; }
|
||||
html += '<div class="partner-preview__pairing">Pairing: ' + pairing + escapeHtml(secondaryName) + '</div>';
|
||||
}
|
||||
if (themes && themes.length){
|
||||
html += '<div class="partner-preview__themes muted">Theme emphasis: ' + themes.map(escapeHtml).join(', ') + '</div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
previewBox.innerHTML = html;
|
||||
previewBox.hidden = false;
|
||||
markSuggestionActive();
|
||||
}
|
||||
|
||||
function setServerPayload(payload){
|
||||
serverPayload = (payload && typeof payload === 'object') ? payload : null;
|
||||
if (!previewBox) return;
|
||||
if (serverPayload){
|
||||
try {
|
||||
previewBox.setAttribute('data-preview-json', JSON.stringify(serverPayload));
|
||||
} catch(_){
|
||||
previewBox.removeAttribute('data-preview-json');
|
||||
}
|
||||
} else {
|
||||
previewBox.removeAttribute('data-preview-json');
|
||||
}
|
||||
}
|
||||
|
||||
function updateAutoNote(note){
|
||||
if (!autoNoteBox) return;
|
||||
var text = (note && String(note).trim()) || '';
|
||||
autoNoteBox.setAttribute('aria-live', 'polite');
|
||||
if (autoNoteCopy){
|
||||
autoNoteCopy.textContent = text;
|
||||
} else {
|
||||
autoNoteBox.textContent = text;
|
||||
}
|
||||
autoNoteBox.hidden = !text;
|
||||
autoNoteBox.setAttribute('aria-hidden', (!text).toString());
|
||||
try { autoNoteBox.setAttribute('data-autonote', text); } catch(_){ }
|
||||
}
|
||||
|
||||
function updateWarnings(list){
|
||||
if (!warningsBox) return;
|
||||
var warnings = Array.isArray(list) ? list.filter(function(msg){ return msg && String(msg).trim(); }) : [];
|
||||
try { warningsBox.setAttribute('data-warnings-json', JSON.stringify(warnings)); } catch(_){ }
|
||||
if (!warnings.length){
|
||||
warningsBox.innerHTML = '';
|
||||
warningsBox.hidden = true;
|
||||
warningsBox.setAttribute('aria-hidden', 'true');
|
||||
return;
|
||||
}
|
||||
var html = '<strong>Warnings</strong><ul style="margin:.35rem 0 0 1.1rem;">';
|
||||
warnings.forEach(function(msg){
|
||||
html += '<li>' + escapeHtml(String(msg)) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
warningsBox.innerHTML = html;
|
||||
warningsBox.hidden = false;
|
||||
warningsBox.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function dispatchPreview(detail){
|
||||
if (typeof document === 'undefined') return;
|
||||
setPreviewState(detail);
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('partner:preview', { detail: detail }));
|
||||
} catch(_){ }
|
||||
}
|
||||
|
||||
function requestPreviewUpdate(){
|
||||
if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
|
||||
if (!primaryName) return;
|
||||
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
|
||||
var bgSel = controls.querySelector('[data-partner-select="background"]');
|
||||
var secondaryVal = partnerSel ? (partnerSel.value || '') : '';
|
||||
var bgVal = bgSel ? (bgSel.value || '') : '';
|
||||
var enabledVal = partnerEnabledInput ? (partnerEnabledInput.value || '') : '1';
|
||||
if (previewAbort){
|
||||
try { previewAbort.abort(); } catch(_){ }
|
||||
}
|
||||
previewAbort = new AbortController();
|
||||
var formData = new FormData();
|
||||
formData.append('commander', primaryName);
|
||||
formData.append('partner_enabled', enabledVal || '1');
|
||||
formData.append('secondary_commander', secondaryVal);
|
||||
formData.append('background', bgVal);
|
||||
formData.append('partner_auto_opt_out', optInput ? (optInput.value || '0') : '0');
|
||||
formData.append('scope', scope || prefix);
|
||||
formData.append('selection_source', selectionSourceInput ? (selectionSourceInput.value || '') : '');
|
||||
fetch('/build/partner/preview', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: previewAbort.signal,
|
||||
}).then(function(resp){
|
||||
if (!resp.ok){ throw new Error('preview request failed'); }
|
||||
return resp.json();
|
||||
}).then(function(data){
|
||||
previewAbort = null;
|
||||
if (!data) return;
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'preview')){
|
||||
setServerPayload(data.preview);
|
||||
if (data.preview){ renderPreview(data.preview); }
|
||||
else { clearPreview(); }
|
||||
}
|
||||
updateAutoNote(data && data.auto_note);
|
||||
updateWarnings(data && data.warnings);
|
||||
var evtDetail = {
|
||||
scope: (data && data.scope) || scope || prefix,
|
||||
payload: (data && data.preview) || null,
|
||||
theme_tags: (data && data.theme_tags) || [],
|
||||
warnings: (data && data.warnings) || [],
|
||||
auto_note: (data && data.auto_note) || null,
|
||||
partner_mode: (data && data.partner_mode) || null,
|
||||
resolved_secondary: Object.prototype.hasOwnProperty.call(data || {}, 'resolved_secondary') ? (data && data.resolved_secondary) : undefined,
|
||||
resolved_background: Object.prototype.hasOwnProperty.call(data || {}, 'resolved_background') ? (data && data.resolved_background) : undefined,
|
||||
secondary_role_label: data && data.preview ? (data.preview.secondary_role_label || data.preview.role_label || null) : null,
|
||||
};
|
||||
dispatchPreview(evtDetail);
|
||||
if (partnerSel && Object.prototype.hasOwnProperty.call(data, 'resolved_secondary')){
|
||||
partnerSel.value = data.resolved_secondary || '';
|
||||
}
|
||||
if (bgSel && Object.prototype.hasOwnProperty.call(data, 'resolved_background')){
|
||||
bgSel.value = data.resolved_background || '';
|
||||
}
|
||||
}).catch(function(err){
|
||||
if (err && err.name === 'AbortError'){ return; }
|
||||
previewAbort = null;
|
||||
});
|
||||
}
|
||||
|
||||
updateAutoNote(initialAutoNote);
|
||||
updateWarnings(initialWarnings);
|
||||
var initialHasPreview = !!(serverPayload && Array.isArray(serverPayload.theme_tags) && serverPayload.theme_tags.length);
|
||||
if (initialHasPreview || initialWarnings.length || (initialAutoNote && initialAutoNote.trim())){
|
||||
setTimeout(function(){
|
||||
dispatchPreview({
|
||||
scope: scope || prefix,
|
||||
payload: serverPayload,
|
||||
theme_tags: (serverPayload && serverPayload.theme_tags) || [],
|
||||
warnings: initialWarnings,
|
||||
auto_note: initialAutoNote || null,
|
||||
partner_mode: serverPayload ? (serverPayload.partner_mode || serverPayload.mode || null) : null,
|
||||
secondary_role_label: serverPayload ? (serverPayload.secondary_role_label || serverPayload.role_label || null) : null,
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function renderFromServer(){
|
||||
if (serverPayload){
|
||||
renderPreview(serverPayload);
|
||||
} else {
|
||||
clearPreview();
|
||||
}
|
||||
}
|
||||
|
||||
function renderFromSelection(sel, modeOverride){
|
||||
if (!sel){
|
||||
if (serverPayload){ renderFromServer(); } else { clearPreview(); }
|
||||
return;
|
||||
}
|
||||
var option = sel.options[sel.selectedIndex];
|
||||
if (!option || !option.value){
|
||||
if (serverPayload){ renderFromServer(); } else { clearPreview(); }
|
||||
return;
|
||||
}
|
||||
var mode = modeOverride || option.getAttribute('data-pairing-mode') || 'partner';
|
||||
var image = option.getAttribute('data-image-url') || '';
|
||||
var link = option.getAttribute('data-scryfall-url') || '';
|
||||
var role = option.getAttribute('data-role-label') || '';
|
||||
if (!image){ image = buildCardImageUrl(option.value); }
|
||||
if (!link){ link = buildScryfallUrl(option.value); }
|
||||
if (!role){ role = defaultRoleForMode(mode); }
|
||||
var payload = {
|
||||
partner_mode: mode,
|
||||
partner_mode_label: option.getAttribute('data-mode-label') || modeLabels[mode] || 'Partner Mechanics',
|
||||
color_label: option.getAttribute('data-color-label') || '',
|
||||
secondary_name: option.value,
|
||||
primary_name: primaryName,
|
||||
secondary_image_url: image,
|
||||
secondary_scryfall_url: link,
|
||||
secondary_role_label: role,
|
||||
};
|
||||
payload.secondary_role_label = role;
|
||||
payload.theme_tags = payload.theme_tags || [];
|
||||
renderPreview(payload);
|
||||
}
|
||||
|
||||
function setOptOut(flag){
|
||||
if (optInput){ optInput.value = flag ? '1' : '0'; }
|
||||
if (autoToggle){
|
||||
autoToggle.classList.toggle('active', !flag);
|
||||
autoToggle.setAttribute('aria-pressed', (!flag).toString());
|
||||
var label = flag ? 'Enable default partner' : 'Use default partner';
|
||||
if (!flag && defaultPartner){ label += ' (' + defaultPartner + ')'; }
|
||||
autoToggle.textContent = label;
|
||||
}
|
||||
markSuggestionActive();
|
||||
}
|
||||
|
||||
function applySuggestionSelection(mode, name){
|
||||
if (!name){ return; }
|
||||
setSelectionSource('suggestion');
|
||||
var normalizedMode = String(mode || '').toLowerCase();
|
||||
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
|
||||
var bgSel = controls.querySelector('[data-partner-select="background"]');
|
||||
if (normalizedMode === 'background'){
|
||||
if (bgSel){ bgSel.value = name; }
|
||||
if (partnerSel){ partnerSel.value = ''; }
|
||||
if (autoToggle){ setOptOut(true); }
|
||||
if (bgSel){
|
||||
renderFromSelection(bgSel, 'background');
|
||||
requestPreviewUpdate();
|
||||
} else {
|
||||
renderFromServer();
|
||||
}
|
||||
} else {
|
||||
if (partnerSel){ partnerSel.value = name; }
|
||||
if (bgSel){ bgSel.value = ''; }
|
||||
if (autoToggle){
|
||||
syncBySelection();
|
||||
} else if (partnerSel){
|
||||
renderFromSelection(partnerSel);
|
||||
requestPreviewUpdate();
|
||||
} else {
|
||||
renderFromServer();
|
||||
}
|
||||
}
|
||||
markSuggestionActive();
|
||||
}
|
||||
|
||||
function syncBySelection(){
|
||||
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
|
||||
if (!partnerSel || !autoToggle || !defaultPartner) return;
|
||||
if (partnerSel.value && partnerSel.value.toLowerCase() === defaultPartner.toLowerCase()){
|
||||
setOptOut(false);
|
||||
renderFromSelection(partnerSel);
|
||||
requestPreviewUpdate();
|
||||
} else if (partnerSel.value) {
|
||||
setOptOut(true);
|
||||
renderFromSelection(partnerSel);
|
||||
requestPreviewUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
if (autoToggle){
|
||||
autoToggle.addEventListener('click', function(){
|
||||
var currentOptOut = optInput && optInput.value === '1';
|
||||
if (currentOptOut){
|
||||
setOptOut(false);
|
||||
setSelectionSource('auto');
|
||||
if (defaultPartner){
|
||||
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
|
||||
if (partnerSel){ partnerSel.value = defaultPartner; }
|
||||
var bgSel = controls.querySelector('[data-partner-select="background"]');
|
||||
if (bgSel){ bgSel.value = ''; }
|
||||
renderFromSelection(partnerSel);
|
||||
} else {
|
||||
renderFromServer();
|
||||
}
|
||||
requestPreviewUpdate();
|
||||
} else {
|
||||
setOptOut(true);
|
||||
setSelectionSource('');
|
||||
selects.forEach(function(sel){
|
||||
if (sel && sel.getAttribute('data-partner-select') === 'secondary'){
|
||||
sel.value = '';
|
||||
}
|
||||
});
|
||||
renderFromServer();
|
||||
requestPreviewUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selects.forEach(function(sel){
|
||||
sel.addEventListener('change', function(){
|
||||
setSelectionSource('manual');
|
||||
var key = sel.getAttribute('data-partner-select');
|
||||
if (key === 'secondary' && sel.value){
|
||||
var bgSel = controls.querySelector('[data-partner-select="background"]');
|
||||
if (bgSel){ bgSel.value = ''; }
|
||||
if (autoToggle){
|
||||
syncBySelection();
|
||||
} else {
|
||||
renderFromSelection(sel);
|
||||
requestPreviewUpdate();
|
||||
}
|
||||
markSuggestionActive();
|
||||
return;
|
||||
}
|
||||
if (key === 'background' && sel.value){
|
||||
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
|
||||
if (partnerSel){ partnerSel.value = ''; }
|
||||
if (autoToggle){ setOptOut(true); }
|
||||
renderFromSelection(sel, 'background');
|
||||
requestPreviewUpdate();
|
||||
markSuggestionActive();
|
||||
return;
|
||||
}
|
||||
if (!sel.value){
|
||||
renderFromServer();
|
||||
requestPreviewUpdate();
|
||||
}
|
||||
markSuggestionActive();
|
||||
});
|
||||
});
|
||||
|
||||
if (suggestionsState){
|
||||
if (suggestionsList){
|
||||
suggestionsList.addEventListener('click', function(evt){
|
||||
var target = evt.target.closest('[data-partner-suggestion]');
|
||||
if (!target){ return; }
|
||||
evt.preventDefault();
|
||||
var mode = target.getAttribute('data-mode') || 'partner';
|
||||
var name = target.getAttribute('data-name') || '';
|
||||
applySuggestionSelection(mode, name);
|
||||
});
|
||||
}
|
||||
if (suggestionsMoreButton){
|
||||
suggestionsMoreButton.addEventListener('click', function(){
|
||||
revealHiddenSuggestions();
|
||||
});
|
||||
}
|
||||
if (suggestionsRefreshButton){
|
||||
suggestionsRefreshButton.addEventListener('click', function(){
|
||||
fetchSuggestions({ includeHidden: suggestionsState.expanded, forceRefresh: true });
|
||||
});
|
||||
}
|
||||
if (suggestionsState.visible && suggestionsState.visible.length){
|
||||
renderSuggestions();
|
||||
} else if (suggestionsState.error){
|
||||
updateSuggestionsMeta();
|
||||
} else {
|
||||
fetchSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
if (clearBtn){
|
||||
clearBtn.addEventListener('click', function(){
|
||||
selects.forEach(function(sel){ if (sel){ sel.value = ''; } });
|
||||
if (autoToggle){ setOptOut(true); }
|
||||
setSelectionSource('');
|
||||
renderFromServer();
|
||||
requestPreviewUpdate();
|
||||
markSuggestionActive();
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.newDeckPartnerState){
|
||||
try {
|
||||
var restore = window.newDeckPartnerState;
|
||||
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
|
||||
var bgSel = controls.querySelector('[data-partner-select="background"]');
|
||||
if (partnerSel && restore.secondary){ partnerSel.value = restore.secondary; }
|
||||
if (bgSel && restore.background){ bgSel.value = restore.background; }
|
||||
if (restore.enabled === false){ selects.forEach(function(sel){ if (sel){ sel.value = ''; } }); }
|
||||
if (partnerSel && partnerSel.value){ renderFromSelection(partnerSel); }
|
||||
else if (bgSel && bgSel.value){ renderFromSelection(bgSel, 'background'); }
|
||||
delete window.newDeckPartnerState;
|
||||
} catch(_){ }
|
||||
}
|
||||
|
||||
if (optInput && optInput.value === '1'){
|
||||
setOptOut(true);
|
||||
renderFromServer();
|
||||
} else {
|
||||
setOptOut(!defaultPartner);
|
||||
if (defaultPartner){ syncBySelection(); }
|
||||
else if (serverPayload){ renderFromServer(); }
|
||||
}
|
||||
markSuggestionActive();
|
||||
|
||||
try {
|
||||
var slot = document.getElementById('newdeck-tags-slot');
|
||||
if (slot) slot.setAttribute('data-has-content', '1');
|
||||
} catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<section>
|
||||
{# Step phases removed #}
|
||||
{% set partner_preview_payload = partner_preview if partner_preview else (combined_commander if combined_commander else None) %}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander.name }}">
|
||||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
|
|
@ -8,6 +9,67 @@
|
|||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
{% if partner_preview_payload %}
|
||||
{% set partner_secondary_name = partner_preview_payload.secondary_name %}
|
||||
{% set partner_role_label = partner_preview_payload.secondary_role_label or 'Partner commander' %}
|
||||
{% set partner_theme_tags = partner_preview_payload.theme_tags if partner_preview_payload.theme_tags else [] %}
|
||||
{% set partner_tags_joined = partner_theme_tags|join(', ') %}
|
||||
{% set partner_primary_name = partner_preview_payload.primary_name or commander.name %}
|
||||
{% set partner_image_url = partner_preview_payload.secondary_image_url or partner_preview_payload.image_url %}
|
||||
{% if partner_secondary_name %}
|
||||
{% set partner_name_base = (partner_secondary_name.split(' - Synergy (')[0] if ' - Synergy (' in partner_secondary_name else partner_secondary_name) %}
|
||||
{% else %}
|
||||
{% set partner_name_base = partner_secondary_name %}
|
||||
{% endif %}
|
||||
{% if not partner_image_url and partner_name_base %}
|
||||
{% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_name_base|urlencode ~ '&format=image&version=normal' %}
|
||||
{% endif %}
|
||||
{% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %}
|
||||
{% if not partner_href and partner_name_base %}
|
||||
{% set partner_href = 'https://scryfall.com/search?q=' ~ partner_name_base|urlencode %}
|
||||
{% endif %}
|
||||
<div class="commander-card partner-card" tabindex="0"
|
||||
data-card-name="{{ partner_name_base }}"
|
||||
data-original-name="{{ partner_secondary_name }}"
|
||||
data-role="{{ partner_role_label }}"
|
||||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>
|
||||
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
|
||||
{% if partner_name_base %}
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
|
||||
width="320"
|
||||
data-card-name="{{ partner_name_base }}"
|
||||
data-original-name="{{ partner_secondary_name }}"
|
||||
data-role="{{ partner_role_label }}"
|
||||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}
|
||||
loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=large 672w"
|
||||
sizes="(max-width: 900px) 100vw, 320px" />
|
||||
{% else %}
|
||||
<img src="{{ partner_image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" width="320" />
|
||||
{% endif %}
|
||||
{% if partner_href %}</a>{% endif %}
|
||||
</div>
|
||||
<div class="muted partner-label" style="margin-top:.35rem;">
|
||||
{{ partner_role_label }}:
|
||||
<span data-card-name="{{ partner_secondary_name }}"
|
||||
data-original-name="{{ partner_secondary_name }}"
|
||||
data-role="{{ partner_role_label }}"
|
||||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>{{ partner_secondary_name }}</span>
|
||||
</div>
|
||||
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
|
||||
Pairing: {{ partner_primary_name }}{% if partner_secondary_name %} + {{ partner_secondary_name }}{% endif %}
|
||||
</div>
|
||||
{% if partner_preview_payload.color_label %}
|
||||
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
|
||||
Colors: {{ partner_preview_payload.color_label }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if partner_theme_tags %}
|
||||
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
|
||||
Theme emphasis: {{ partner_theme_tags|join(', ') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
|
|
@ -40,29 +102,33 @@
|
|||
</div>
|
||||
<div id="combine-help-tip" class="muted" style="font-size:12px; margin:-.15rem 0 .5rem 0;">Tip: Choose OR for a stronger initial theme pool; switch to AND to tighten synergy.</div>
|
||||
<div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></div>
|
||||
{% if recommended and recommended|length %}
|
||||
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
|
||||
<div class="muted" style="font-size:12px;">Recommended</div>
|
||||
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?">Why?</button>
|
||||
<div id="tag-reco-block" data-has-reco="{% if recommended and recommended|length %}1{% else %}0{% endif %}" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>
|
||||
<div id="tag-reco-header" style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
|
||||
<div class="muted" style="font-size:12px;">Recommended</div>
|
||||
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Why?</button>
|
||||
</div>
|
||||
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" data-default-reasons='{{ (recommended_reasons or {}) | tojson }}' style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
|
||||
<div class="reco-why-title" style="font-size:12px; color:#222; margin-bottom:.5rem;">Why these themes? <span class="muted" style="color:#555;">Signals from oracle text, color identity, and your local build history.</span></div>
|
||||
<ul class="reco-why-list" style="margin:.25rem 0; padding-left:1.1rem;">
|
||||
{% if recommended and recommended|length %}
|
||||
{% for r in recommended %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'From this commander\'s theme list') %}
|
||||
<li style="font-size:12px; color:#222; line-height:1.35;"><strong>{{ r }}</strong>: <span style="color:#333;">{{ tip }}</span></li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<div id="tag-reco-list" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% if recommended and recommended|length %}
|
||||
{% for r in recommended %}
|
||||
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
|
||||
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
|
||||
<div style="font-size:12px; color:#222; margin-bottom:.5rem;">Why these themes? <span class="muted" style="color:#555;">Signals from oracle text, color identity, and your local build history.</span></div>
|
||||
<ul style="margin:.25rem 0; padding-left:1.1rem;">
|
||||
{% for r in recommended %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'From this commander\'s theme list') %}
|
||||
<li style="font-size:12px; color:#222; line-height:1.35;"><strong>{{ r }}</strong>: <span style="color:#333;">{{ tip }}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div id="tag-reco-list" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% for r in recommended %}
|
||||
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
|
||||
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% endfor %}
|
||||
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in tags %}
|
||||
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
|
||||
|
|
@ -74,6 +140,10 @@
|
|||
{% endif %}
|
||||
</fieldset>
|
||||
|
||||
{% set partner_id_prefix = 'step2' %}
|
||||
{% set partner_scope = 'step2' %}
|
||||
{% include "build/_partner_controls.html" %}
|
||||
|
||||
<fieldset>
|
||||
<legend>Budget/Power Bracket</legend>
|
||||
<div style="display:grid; gap:.5rem;">
|
||||
|
|
@ -108,8 +178,8 @@
|
|||
<script>
|
||||
(function(){
|
||||
var chipHost = document.getElementById('tag-chip-list');
|
||||
var recoBlock = document.getElementById('tag-reco-block');
|
||||
var recoHost = document.getElementById('tag-reco-list');
|
||||
var selAll = document.getElementById('reco-select-all');
|
||||
var resetBtn = document.getElementById('reset-tags');
|
||||
var primary = document.getElementById('primary_tag');
|
||||
var secondary = document.getElementById('secondary_tag');
|
||||
|
|
@ -117,12 +187,52 @@
|
|||
var tagMode = document.getElementById('tag_mode');
|
||||
var countEl = document.getElementById('tag-count');
|
||||
var orderEl = document.getElementById('tag-order');
|
||||
var whyBtn = document.getElementById('reco-why');
|
||||
var whyPanel = document.getElementById('reco-why-panel');
|
||||
var commander = '{{ commander.name|e }}';
|
||||
var clearPersisted = '{{ (clear_persisted|default(false)) and "1" or "0" }}' === '1';
|
||||
if (!chipHost) return;
|
||||
|
||||
function escapeHtml(str){
|
||||
return String(str || "").replace(/[&<>"']/g, function(ch){
|
||||
return ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]);
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectAllBtn(){ return document.getElementById('reco-select-all'); }
|
||||
function getRecoHost(){ return document.getElementById('tag-reco-list'); }
|
||||
function getRecoBlock(){ return document.getElementById('tag-reco-block'); }
|
||||
function getWhyBtn(){ return document.getElementById('reco-why'); }
|
||||
function getWhyPanel(){ return document.getElementById('reco-why-panel'); }
|
||||
function originalRecommendedTags(){
|
||||
var host = getRecoHost();
|
||||
if (!host || !host.dataset.originalTags) return [];
|
||||
try { var parsed = JSON.parse(host.dataset.originalTags); return Array.isArray(parsed) ? parsed : []; }
|
||||
catch(_){ return []; }
|
||||
}
|
||||
function defaultReasonMap(){
|
||||
var panel = getWhyPanel();
|
||||
if (!panel || !panel.getAttribute('data-default-reasons')) return {};
|
||||
try { var parsed = JSON.parse(panel.getAttribute('data-default-reasons')); return parsed && typeof parsed === 'object' ? parsed : {}; }
|
||||
catch(_){ return {}; }
|
||||
}
|
||||
|
||||
var previewScope = 'step2';
|
||||
|
||||
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
|
||||
|
||||
function readPartnerPreviewTags(){
|
||||
if (typeof window === 'undefined') return [];
|
||||
var store = window.partnerPreviewState;
|
||||
if (!store) return [];
|
||||
var state = store[previewScope];
|
||||
if (!state) return [];
|
||||
if (Array.isArray(state.theme_tags) && state.theme_tags.length){ return state.theme_tags.slice(); }
|
||||
var payload = state.payload;
|
||||
if (payload && Array.isArray(payload.theme_tags)){ return payload.theme_tags.slice(); }
|
||||
return [];
|
||||
}
|
||||
|
||||
function getSelected(){
|
||||
var arr = [];
|
||||
if (primary && primary.value) arr.push(primary.value);
|
||||
|
|
@ -231,91 +341,196 @@
|
|||
});
|
||||
if (resetBtn) resetBtn.addEventListener('click', function(){ setSelected([]); updateChipsState(); });
|
||||
|
||||
// attach handlers to existing chips
|
||||
Array.prototype.forEach.call(chipHost.querySelectorAll('button.chip'), function(btn){
|
||||
chipHost.addEventListener('click', function(e){
|
||||
var btn = e.target.closest('button.chip');
|
||||
if (!btn || !chipHost.contains(btn)) return;
|
||||
var t = btn.dataset.tag || '';
|
||||
btn.addEventListener('click', function(){ toggleTag(t); });
|
||||
btn.addEventListener('keydown', function(e){
|
||||
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleTag(t); }
|
||||
else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
var chips = Array.prototype.slice.call(chipHost.querySelectorAll('button.chip'));
|
||||
var ix = chips.indexOf(e.currentTarget);
|
||||
var next = (e.key === 'ArrowRight') ? chips[Math.min(ix+1, chips.length-1)] : chips[Math.max(ix-1, 0)];
|
||||
if (next) { try { next.focus(); } catch(_){ } }
|
||||
}
|
||||
});
|
||||
if (!t) return;
|
||||
toggleTag(t);
|
||||
});
|
||||
|
||||
chipHost.addEventListener('keydown', function(e){
|
||||
var btn = e.target.closest('button.chip');
|
||||
if (!btn || !chipHost.contains(btn)) return;
|
||||
var t = btn.dataset.tag || '';
|
||||
if (!t) return;
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggleTag(t);
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
var chips = Array.prototype.slice.call(chipHost.querySelectorAll('button.chip'));
|
||||
var ix = chips.indexOf(btn);
|
||||
if (ix >= 0){
|
||||
var next = (e.key === 'ArrowRight') ? chips[Math.min(ix+1, chips.length-1)] : chips[Math.max(ix-1, 0)];
|
||||
if (next && next.focus){
|
||||
try { next.focus(); } catch(_){ }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// attach handlers to recommended chips and select-all
|
||||
if (recoHost){
|
||||
Array.prototype.forEach.call(recoHost.querySelectorAll('button.chip-reco'), function(btn){
|
||||
var t = btn.dataset.tag || '';
|
||||
btn.addEventListener('click', function(){ toggleTag(t); });
|
||||
});
|
||||
if (selAll){
|
||||
selAll.addEventListener('click', function(){
|
||||
recoHost.addEventListener('click', function(e){
|
||||
var btn = e.target.closest('button');
|
||||
if (!btn || !recoHost.contains(btn)) return;
|
||||
if (btn.id === 'reco-select-all'){
|
||||
e.preventDefault();
|
||||
try {
|
||||
var sel = getSelected();
|
||||
var recs = Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean);
|
||||
var combined = sel.slice();
|
||||
recs.forEach(function(t){ if (combined.indexOf(t) === -1) combined.push(t); });
|
||||
combined = combined.slice(-3); // keep last 3
|
||||
combined = combined.slice(-3);
|
||||
setSelected(combined);
|
||||
updateChipsState();
|
||||
updateSelectAllState();
|
||||
} catch(_){ }
|
||||
});
|
||||
}
|
||||
// Why recommended panel toggle
|
||||
var whyBtn = document.getElementById('reco-why');
|
||||
var whyPanel = document.getElementById('reco-why-panel');
|
||||
function setWhy(open){
|
||||
if (!whyBtn || !whyPanel) return;
|
||||
whyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
whyPanel.style.display = open ? 'block' : 'none';
|
||||
whyPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
|
||||
}
|
||||
if (whyBtn && whyPanel){
|
||||
whyBtn.addEventListener('click', function(e){
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (btn.classList.contains('chip-reco')){
|
||||
var t = btn.dataset.tag || '';
|
||||
if (t){ toggleTag(t); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleWhyPanel(open){
|
||||
if (!whyBtn || !whyPanel) return;
|
||||
whyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
whyPanel.style.display = open ? 'block' : 'none';
|
||||
whyPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
|
||||
}
|
||||
|
||||
if (whyBtn && whyPanel){
|
||||
whyBtn.addEventListener('click', function(e){
|
||||
e.stopPropagation();
|
||||
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
|
||||
toggleWhyPanel(!isOpen);
|
||||
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){ } }
|
||||
});
|
||||
document.addEventListener('click', function(e){
|
||||
try {
|
||||
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
|
||||
setWhy(!isOpen);
|
||||
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){} }
|
||||
});
|
||||
document.addEventListener('click', function(e){
|
||||
try {
|
||||
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
|
||||
if (!isOpen) return;
|
||||
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
|
||||
setWhy(false);
|
||||
} catch(_){}
|
||||
});
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Escape'){ setWhy(false); }
|
||||
});
|
||||
if (!isOpen) return;
|
||||
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
|
||||
toggleWhyPanel(false);
|
||||
} catch(_){ }
|
||||
});
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Escape'){ toggleWhyPanel(false); }
|
||||
});
|
||||
}
|
||||
|
||||
function refreshWhyPanel(partnerTags){
|
||||
var panel = getWhyPanel();
|
||||
if (!panel) return;
|
||||
var list = panel.querySelector('.reco-why-list');
|
||||
if (!list) return;
|
||||
var reasons = defaultReasonMap();
|
||||
var base = originalRecommendedTags();
|
||||
var seen = new Set();
|
||||
var items = [];
|
||||
base.forEach(function(tag){
|
||||
var value = String(tag || '').trim();
|
||||
if (!value) return;
|
||||
var key = value.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
var tip = reasons && reasons[value] ? reasons[value] : 'From this commander\'s theme list';
|
||||
items.push('<li style="font-size:12px; color:#222; line-height:1.35;"><strong>' + escapeHtml(value) + '</strong>: <span style="color:#333;">' + escapeHtml(tip) + '</span></li>');
|
||||
});
|
||||
(Array.isArray(partnerTags) ? partnerTags : []).forEach(function(tag){
|
||||
var value = String(tag || '').trim();
|
||||
if (!value) return;
|
||||
var key = value.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
items.push('<li style="font-size:12px; color:#222; line-height:1.35;"><strong>' + escapeHtml(value) + '</strong>: <span style="color:#333;">Synergizes with selected partner pairing</span></li>');
|
||||
});
|
||||
list.innerHTML = items.join('');
|
||||
if (!items.length){
|
||||
toggleWhyPanel(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePartnerRecommendations(tags){
|
||||
var host = getRecoHost();
|
||||
var block = getRecoBlock();
|
||||
if (!host || !block) return;
|
||||
var selectAllBtn = getSelectAllBtn();
|
||||
Array.prototype.slice.call(host.querySelectorAll('button.partner-suggestion')).forEach(function(btn){ btn.remove(); });
|
||||
var unique = [];
|
||||
var seen = new Set();
|
||||
(Array.isArray(tags) ? tags : []).forEach(function(tag){
|
||||
var value = String(tag || '').trim();
|
||||
if (!value) return;
|
||||
var key = value.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
unique.push(value);
|
||||
});
|
||||
var insertBefore = selectAllBtn && selectAllBtn.parentElement === host ? selectAllBtn : null;
|
||||
unique.forEach(function(tag){
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip chip-reco partner-suggestion';
|
||||
btn.dataset.tag = tag;
|
||||
btn.setAttribute('aria-pressed', getSelected().indexOf(tag) >= 0 ? 'true' : 'false');
|
||||
btn.title = 'Synergizes with selected partner pairing';
|
||||
btn.textContent = '★ ' + tag;
|
||||
if (insertBefore){ host.insertBefore(btn, insertBefore); }
|
||||
else { host.appendChild(btn); }
|
||||
});
|
||||
var hasAny = host.querySelectorAll('button.chip-reco').length > 0;
|
||||
block.style.display = hasAny ? '' : 'none';
|
||||
block.setAttribute('data-has-reco', hasAny ? '1' : '0');
|
||||
var btnEl = getWhyBtn();
|
||||
if (btnEl){ btnEl.style.display = hasAny ? '' : 'none'; }
|
||||
if (selectAllBtn){ selectAllBtn.style.display = hasAny ? '' : 'none'; }
|
||||
refreshWhyPanel(unique);
|
||||
updateSelectAllState();
|
||||
updateChipsState();
|
||||
}
|
||||
|
||||
function updateSelectAllState(){
|
||||
try {
|
||||
if (!selAll) return;
|
||||
var selAllBtn = getSelectAllBtn();
|
||||
if (!selAllBtn) return;
|
||||
var sel = getSelected();
|
||||
var recs = recoHost ? Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean) : [];
|
||||
var host = getRecoHost();
|
||||
var recs = host ? Array.prototype.slice.call(host.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean) : [];
|
||||
var unselected = recs.filter(function(t){ return sel.indexOf(t) === -1; });
|
||||
var atCap = sel.length >= 3;
|
||||
var noNew = unselected.length === 0;
|
||||
var disable = atCap || noNew;
|
||||
selAll.disabled = disable;
|
||||
selAll.setAttribute('aria-disabled', disable ? 'true' : 'false');
|
||||
selAllBtn.disabled = disable;
|
||||
selAllBtn.setAttribute('aria-disabled', disable ? 'true' : 'false');
|
||||
if (disable){
|
||||
selAll.title = atCap ? 'Already have 3 themes selected' : 'All recommended already selected';
|
||||
selAllBtn.title = atCap ? 'Already have 3 themes selected' : 'All recommended already selected';
|
||||
} else {
|
||||
selAll.title = 'Add recommended up to 3';
|
||||
selAllBtn.title = 'Add recommended up to 3';
|
||||
}
|
||||
} catch(_){ }
|
||||
}
|
||||
|
||||
document.addEventListener('partner:preview', function(evt){
|
||||
var detail = (evt && evt.detail) || {};
|
||||
if (detail.scope && detail.scope !== previewScope) return;
|
||||
var tags = Array.isArray(detail.theme_tags) && detail.theme_tags.length ? detail.theme_tags : [];
|
||||
if (!tags.length && detail.payload && Array.isArray(detail.payload.theme_tags)){
|
||||
tags = detail.payload.theme_tags;
|
||||
}
|
||||
updatePartnerRecommendations(tags);
|
||||
});
|
||||
|
||||
var initialPartnerTags = readPartnerPreviewTags();
|
||||
if (initialPartnerTags.length){
|
||||
updatePartnerRecommendations(initialPartnerTags);
|
||||
} else {
|
||||
refreshWhyPanel([]);
|
||||
}
|
||||
|
||||
// initial: set from template-selected values, then maybe load persisted if none
|
||||
updateChipsState();
|
||||
loadPersisted();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,25 @@
|
|||
{% from 'partials/_macros.html' import color_identity %}
|
||||
{% set combined = combined_commander if combined_commander else {} %}
|
||||
{% set display_commander_name = commander_display_name or commander %}
|
||||
{% if not display_commander_name %}
|
||||
{% set display_commander_name = commander %}
|
||||
{% endif %}
|
||||
{% set color_identity_list = commander_color_identity if commander_color_identity else [] %}
|
||||
{% if not color_identity_list and summary and summary.colors %}
|
||||
{% set color_identity_list = summary.colors %}
|
||||
{% endif %}
|
||||
{% set color_label = commander_color_label %}
|
||||
{% if not color_label and color_identity_list %}
|
||||
{% set color_label = color_identity_list|join(' / ') %}
|
||||
{% endif %}
|
||||
{% if not color_label and (color_identity_list|length == 0) and combined %}
|
||||
{% set color_label = 'Colorless (C)' %}
|
||||
{% endif %}
|
||||
{% set display_tags_source = deck_theme_tags if deck_theme_tags else (tags if tags else commander_combined_tags) %}
|
||||
{% set hover_tags_source = deck_theme_tags if deck_theme_tags else commander_combined_tags %}
|
||||
{% set hover_tags_joined = hover_tags_source|join(', ') %}
|
||||
{% set display_tags = display_tags_source if display_tags_source else [] %}
|
||||
{% set show_color_identity = color_label or (color_identity_list|length > 0) %}
|
||||
<section>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
|
|
@ -9,16 +31,16 @@
|
|||
data-card-name="{{ commander_base }}"
|
||||
data-original-name="{{ commander }}"
|
||||
data-role="{{ commander_role_label or 'Commander' }}"
|
||||
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image"
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image"
|
||||
width="320"
|
||||
data-card-name="{{ commander_base }}"
|
||||
data-original-name="{{ commander }}"
|
||||
data-role="{{ commander_role_label or 'Commander' }}"
|
||||
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}
|
||||
|
|
@ -30,11 +52,67 @@
|
|||
Commander: <span data-card-name="{{ commander }}"
|
||||
data-original-name="{{ commander }}"
|
||||
data-role="{{ commander_role_label or 'Commander' }}"
|
||||
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ commander }}</span>
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ display_commander_name or commander }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if combined and combined.secondary_name %}
|
||||
{% set partner_secondary_name = combined.secondary_name %}
|
||||
{% set partner_role_label = combined.secondary_role_label or ('Background' if (combined.partner_mode == 'background') else 'Partner commander') %}
|
||||
{% set partner_theme_tags = combined.theme_tags if combined.theme_tags else [] %}
|
||||
{% set partner_tags_joined = partner_theme_tags|join(', ') %}
|
||||
{% if partner_secondary_name %}
|
||||
{% set partner_name_base = (partner_secondary_name.split(' - Synergy (')[0] if ' - Synergy (' in partner_secondary_name else partner_secondary_name) %}
|
||||
{% else %}
|
||||
{% set partner_name_base = partner_secondary_name %}
|
||||
{% endif %}
|
||||
{% set partner_href = combined.secondary_scryfall_url or combined.scryfall_url %}
|
||||
{% if not partner_href and partner_name_base %}
|
||||
{% set partner_href = 'https://scryfall.com/search?q=' ~ partner_name_base|urlencode %}
|
||||
{% endif %}
|
||||
<div class="commander-card partner-card" tabindex="0"
|
||||
data-card-name="{{ partner_name_base }}"
|
||||
data-original-name="{{ partner_secondary_name }}"
|
||||
data-role="{{ partner_role_label }}"
|
||||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>
|
||||
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
|
||||
{% if partner_name_base %}
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
|
||||
width="320"
|
||||
data-card-name="{{ partner_name_base }}"
|
||||
data-original-name="{{ partner_secondary_name }}"
|
||||
data-role="{{ partner_role_label }}"
|
||||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}
|
||||
loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=large 672w"
|
||||
sizes="(max-width: 900px) 100vw, 320px" />
|
||||
{% else %}
|
||||
<img src="{{ combined.secondary_image_url or combined.image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" width="320" />
|
||||
{% endif %}
|
||||
{% if partner_href %}</a>{% endif %}
|
||||
</div>
|
||||
<div class="muted partner-label" style="margin-top:.35rem;">
|
||||
{{ partner_role_label }}:
|
||||
<span data-card-name="{{ partner_secondary_name }}"
|
||||
data-original-name="{{ partner_secondary_name }}"
|
||||
data-role="{{ partner_role_label }}"
|
||||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>{{ partner_secondary_name }}</span>
|
||||
</div>
|
||||
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
|
||||
Pairing: {{ combined.primary_name or display_commander_name or commander }}{% if partner_secondary_name %} + {{ partner_secondary_name }}{% endif %}
|
||||
</div>
|
||||
{% if combined.color_label %}
|
||||
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
|
||||
Colors: {{ combined.color_label }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if partner_theme_tags %}
|
||||
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
|
||||
Theme emphasis: {{ partner_theme_tags|join(', ') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
|
|
@ -63,15 +141,21 @@
|
|||
data-card-name="{{ commander }}"
|
||||
data-original-name="{{ commander }}"
|
||||
data-role="{{ commander_role_label or 'Commander' }}"
|
||||
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ commander }}</strong>
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ display_commander_name or commander }}</strong>
|
||||
{% else %}
|
||||
<strong>None selected</strong>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>Tags: {{ deck_theme_tags|default([])|join(', ') }}</p>
|
||||
{% if show_color_identity %}
|
||||
<div class="muted" style="display:flex; align-items:center; gap:.35rem; margin:-.35rem 0 .5rem 0;">
|
||||
{{ color_identity(color_identity_list, is_colorless=(color_identity_list|length == 0), aria_label=color_label or '', title_text=color_label or '') }}
|
||||
<span>{{ color_label }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p>Tags: {% if display_tags %}{{ display_tags|join(', ') }}{% else %}—{% endif %}</p>
|
||||
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@
|
|||
{% if display_name %}
|
||||
<div><strong>{{ display_name }}</strong></div>
|
||||
{% endif %}
|
||||
{% set hover_tags_source = deck_theme_tags if deck_theme_tags else (tags if tags else commander_combined_tags) %}
|
||||
{% set hover_tags_joined = hover_tags_source|join(', ') %}
|
||||
<div class="muted">Commander:
|
||||
<strong class="commander-hover"
|
||||
data-card-name="{{ commander }}"
|
||||
data-original-name="{{ commander }}"
|
||||
data-role="{{ commander_role_label }}"
|
||||
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ commander }}</strong>
|
||||
|
|
@ -29,7 +31,7 @@
|
|||
data-card-name="{{ commander_base }}"
|
||||
data-original-name="{{ commander }}"
|
||||
data-role="{{ commander_role_label }}"
|
||||
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>
|
||||
|
|
@ -38,7 +40,7 @@
|
|||
data-card-name="{{ commander_base }}"
|
||||
data-original-name="{{ commander }}"
|
||||
data-role="{{ commander_role_label }}"
|
||||
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}
|
||||
|
|
@ -47,7 +49,7 @@
|
|||
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}"
|
||||
data-original-name="{{ commander }}"
|
||||
data-role="{{ commander_role_label }}"
|
||||
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ commander }}</span></div>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@
|
|||
{% endif %}
|
||||
<div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div>
|
||||
</div>
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Dual-Commander diagnostics</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem;">Latest partner, partner-with, doctor, and background pairings with color sources.</div>
|
||||
<div id="partnerMetricsSummary" class="muted">Loading partner metrics…</div>
|
||||
<div id="partnerMetricsModes" class="muted" style="margin-top:.5rem;"></div>
|
||||
<div id="partnerColorSources" style="margin-top:.5rem;"></div>
|
||||
</div>
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Performance (local)</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
|
||||
|
|
@ -436,6 +443,160 @@
|
|||
.catch(function(){ dfcMetricsEl.textContent = 'MDFC metrics unavailable'; });
|
||||
}
|
||||
loadDfcMetrics();
|
||||
var partnerSummaryEl = document.getElementById('partnerMetricsSummary');
|
||||
var partnerModesEl = document.getElementById('partnerMetricsModes');
|
||||
var partnerSourcesEl = document.getElementById('partnerColorSources');
|
||||
function escapeHtml(str){
|
||||
return String(str == null ? '' : str).replace(/[&<>"']/g, function(ch){
|
||||
return ({"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"}[ch]) || ch;
|
||||
});
|
||||
}
|
||||
function labelForPartnerRole(role){
|
||||
var key = role == null ? '' : String(role).toLowerCase();
|
||||
var map = {
|
||||
'primary': 'Primary',
|
||||
'partner': 'Partner commander',
|
||||
'partner_with': 'Partner With',
|
||||
'background': 'Background',
|
||||
'companion': "Doctor's Companion",
|
||||
'doctor_companion': "Doctor's Companion",
|
||||
'doctor': 'Doctor',
|
||||
'secondary': 'Secondary',
|
||||
};
|
||||
if (map[key]) return map[key];
|
||||
if (!key) return '';
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, function(ch){ return ch.toUpperCase(); });
|
||||
}
|
||||
function labelForPartnerMode(mode){
|
||||
var key = mode == null ? 'none' : String(mode).toLowerCase();
|
||||
var map = {
|
||||
'none': 'Single commander',
|
||||
'partner': 'Partner',
|
||||
'partner_with': 'Partner With',
|
||||
'background': 'Choose a Background',
|
||||
'doctor_companion': "Doctor & Companion",
|
||||
'doctor': 'Doctor',
|
||||
};
|
||||
return map[key] || labelForPartnerRole(key) || key;
|
||||
}
|
||||
function buildModeCountsHtml(modeCounts, total){
|
||||
var html = '<div><strong>Total pairings observed:</strong> ' + String(total || 0) + '</div>';
|
||||
var keys = Object.keys(modeCounts || {}).filter(function(k){ return Number(modeCounts[k] || 0) > 0; });
|
||||
if (keys.length){
|
||||
var parts = keys.sort().map(function(k){
|
||||
return labelForPartnerMode(k) + ': ' + String(modeCounts[k]);
|
||||
});
|
||||
html += '<div style="font-size:12px;">Mode breakdown: ' + parts.join(' · ') + '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
function renderPartnerMetrics(payload){
|
||||
if (!partnerSummaryEl) return;
|
||||
try{
|
||||
if (!payload || payload.ok !== true){
|
||||
partnerSummaryEl.textContent = 'Partner metrics unavailable';
|
||||
if (partnerModesEl) partnerModesEl.textContent = '';
|
||||
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var metrics = payload.metrics || {};
|
||||
var total = Number(metrics.total_pairs || 0);
|
||||
var modeCounts = metrics.mode_counts || {};
|
||||
var last = metrics.last_summary || null;
|
||||
var updated = metrics.last_updated || '';
|
||||
if (!total || !last){
|
||||
partnerSummaryEl.textContent = 'No partner/background builds recorded yet.';
|
||||
if (partnerModesEl) partnerModesEl.innerHTML = buildModeCountsHtml(modeCounts, total);
|
||||
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var primary = last.primary != null ? String(last.primary) : '';
|
||||
var secondary = last.secondary != null ? String(last.secondary) : '';
|
||||
if (!primary && Array.isArray(last.names) && last.names.length){ primary = String(last.names[0] || ''); }
|
||||
if (!secondary && Array.isArray(last.names) && last.names.length > 1){ secondary = String(last.names[1] || ''); }
|
||||
var header = '<div><strong>Latest pairing:</strong> ' + escapeHtml(primary || '—');
|
||||
if (secondary){ header += ' + ' + escapeHtml(secondary); }
|
||||
header += '</div>';
|
||||
header += '<div><strong>Mode:</strong> ' + escapeHtml(labelForPartnerMode(last.partner_mode)) + '</div>';
|
||||
var colorLabel = last.color_label != null ? String(last.color_label) : '';
|
||||
var colorCode = last.color_code != null ? String(last.color_code) : '';
|
||||
var colors = Array.isArray(last.color_identity) ? last.color_identity.filter(Boolean).map(String).join(' / ') : '';
|
||||
if (colorLabel || colorCode || colors){
|
||||
var labelText = colorLabel || colors || colorCode;
|
||||
var extra = (!colorLabel && colorCode && colorCode !== labelText) ? ' (' + escapeHtml(colorCode) + ')' : '';
|
||||
if (colorLabel && colorCode && colorLabel.indexOf(colorCode) === -1){ extra = ' (' + escapeHtml(colorCode) + ')'; }
|
||||
header += '<div><strong>Colors:</strong> ' + escapeHtml(labelText) + extra + '</div>';
|
||||
}
|
||||
if (updated){
|
||||
header += '<div style="font-size:11px; opacity:0.75;">Last updated: ' + escapeHtml(updated) + '</div>';
|
||||
}
|
||||
partnerSummaryEl.innerHTML = header;
|
||||
if (partnerModesEl){
|
||||
partnerModesEl.innerHTML = buildModeCountsHtml(modeCounts, total);
|
||||
}
|
||||
if (partnerSourcesEl){
|
||||
var sources = Array.isArray(last.color_sources) ? last.color_sources : [];
|
||||
if (!sources.length){
|
||||
partnerSourcesEl.innerHTML = '<div class="muted">No color source breakdown recorded.</div>';
|
||||
} else {
|
||||
var html = '<div><strong>Color sources</strong></div>';
|
||||
html += '<ul style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.25rem;">';
|
||||
sources.forEach(function(entry){
|
||||
var color = entry && entry.color != null ? String(entry.color) : '?';
|
||||
var providers = Array.isArray(entry && entry.providers) ? entry.providers : [];
|
||||
var providerParts = providers.map(function(provider){
|
||||
var name = provider && provider.name != null ? String(provider.name) : 'Unknown';
|
||||
var roleLabel = labelForPartnerRole(provider && provider.role);
|
||||
if (roleLabel){
|
||||
return escapeHtml(name) + ' [' + escapeHtml(roleLabel) + ']';
|
||||
}
|
||||
return escapeHtml(name);
|
||||
});
|
||||
if (!providerParts.length){ providerParts.push('—'); }
|
||||
html += '<li class="muted"><span class="chip" style="display:inline-flex; align-items:center; gap:.25rem;"><span class="dot" style="background: var(--border);"></span> ' + escapeHtml(color) + '</span> ' + providerParts.join(', ') + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
var delta = last.color_delta || {};
|
||||
try{
|
||||
var deltaParts = [];
|
||||
var added = Array.isArray(delta.added) ? delta.added.filter(Boolean) : [];
|
||||
var removed = Array.isArray(delta.removed) ? delta.removed.filter(Boolean) : [];
|
||||
if (added.length){ deltaParts.push('Added ' + added.map(escapeHtml).join(', ')); }
|
||||
if (removed.length){ deltaParts.push('Removed ' + removed.map(escapeHtml).join(', ')); }
|
||||
if (deltaParts.length){
|
||||
html += '<div class="muted" style="font-size:12px; margin-top:.35rem;">' + deltaParts.join(' · ') + '</div>';
|
||||
}
|
||||
}catch(_){ }
|
||||
partnerSourcesEl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}catch(_){
|
||||
partnerSummaryEl.textContent = 'Partner metrics unavailable';
|
||||
if (partnerModesEl) partnerModesEl.textContent = '';
|
||||
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
function loadPartnerMetrics(){
|
||||
if (!partnerSummaryEl) return;
|
||||
partnerSummaryEl.textContent = 'Loading partner metrics…';
|
||||
fetch('/status/partner_metrics', { cache: 'no-store' })
|
||||
.then(function(resp){
|
||||
if (resp.status === 404){
|
||||
partnerSummaryEl.textContent = 'Diagnostics disabled (partner metrics unavailable)';
|
||||
if (partnerModesEl) partnerModesEl.textContent = '';
|
||||
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
|
||||
return null;
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function(data){ if (data) renderPartnerMetrics(data); })
|
||||
.catch(function(){
|
||||
partnerSummaryEl.textContent = 'Partner metrics unavailable';
|
||||
if (partnerModesEl) partnerModesEl.textContent = '';
|
||||
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
|
||||
});
|
||||
}
|
||||
loadPartnerMetrics();
|
||||
// Theme status and reset
|
||||
try{
|
||||
var tEl = document.getElementById('themeSummary');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
"secondary_tag": "Airbending",
|
||||
"tertiary_tag": "Token Creation",
|
||||
"bracket_level": 4,
|
||||
"secondary_commander": null,
|
||||
"background": null,
|
||||
"enable_partner_mechanics": false,
|
||||
"use_multi_theme": true,
|
||||
"add_lands": true,
|
||||
"add_creatures": true,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -62,6 +62,8 @@ min_frequency_overrides:
|
|||
Treasure Token: 0
|
||||
Monarch: 0
|
||||
Initiative: 0
|
||||
Partner - Father & Son: 0
|
||||
Friends Forever: 0
|
||||
Pillow Fort: 0 # alias that may appear; normalization may fold it
|
||||
|
||||
normalization:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ services:
|
|||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_MISC_POOL: "0"
|
||||
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
||||
# Partner / Background mechanics (feature flag)
|
||||
ENABLE_PARTNER_MECHANICS: "1" # 1=unlock partner/background commander inputs
|
||||
ENABLE_PARTNER_SUGGESTIONS: "1" # 1=enable partner suggestion API/UI (requires dataset)
|
||||
# PARTNER_SUGGESTIONS_DATASET: "/app/config/analytics/partner_synergy.json" # Optional override path for dataset inside container
|
||||
# Sampling experiments
|
||||
# SPLASH_ADAPTIVE: "0" # 1=enable adaptive splash penalty scaling by commander color count
|
||||
# SPLASH_ADAPTIVE_SCALE: "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35" # override default scaling
|
||||
|
|
@ -228,4 +232,4 @@ services:
|
|||
- ${PWD}/config:/app/config
|
||||
- ${PWD}/owned_cards:/app/owned_cards
|
||||
working_dir: /app
|
||||
restart: "no"
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ services:
|
|||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_MISC_POOL: "0"
|
||||
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
||||
# HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON
|
||||
ENABLE_PARTNER_MECHANICS: "1" # 1=unlock partner/background commander inputs
|
||||
ENABLE_PARTNER_SUGGESTIONS: "1" # 1=enable partner suggestion API/UI (requires dataset)
|
||||
# PARTNER_SUGGESTIONS_DATASET: "/app/config/analytics/partner_synergy.json" # Optional override path for dataset inside container
|
||||
# Sampling experiments
|
||||
# SPLASH_ADAPTIVE: "0" # 1=enable adaptive splash penalty scaling by commander color count
|
||||
# SPLASH_ADAPTIVE_SCALE: "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35" # override default scaling
|
||||
|
|
@ -148,7 +152,6 @@ services:
|
|||
|
||||
# Headless-only settings
|
||||
# DECK_MODE: "headless" # Auto-run headless flow in CLI mode
|
||||
# HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON
|
||||
# DECK_COMMANDER: "" # Commander name query
|
||||
# DECK_PRIMARY_CHOICE: "1" # Primary tag index (1-based)
|
||||
# DECK_SECONDARY_CHOICE: "" # Optional secondary index
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ Set `APP_MODE=cli` to switch from the Web UI to the textual interface. Add `DECK
|
|||
- Drop JSON files into `config/` (e.g., `config/deck.json`).
|
||||
- Headless mode auto-runs the lone JSON file; if multiple exist, the CLI lists them with summaries (commander + themes).
|
||||
- Config fields cover commander, bracket, include/exclude lists, theme preferences, owned-mode toggles, and output naming.
|
||||
- Partner mechanics are optional: set `"enable_partner_mechanics": true` and supply either `"secondary_commander"` or `"background"` for combined commander runs.
|
||||
|
||||
## Environment overrides
|
||||
When running in containers or automation, environment variables can override JSON settings. Typical variables include:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue