feat: Added Partners, Backgrounds, and related variation selections to commander building.

This commit is contained in:
matt 2025-10-06 09:17:59 -07:00
parent 641b305955
commit d416c9b238
65 changed files with 11835 additions and 691 deletions

View file

@ -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)
############################

View file

@ -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/Doctors 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 Doctors 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/Doctors 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 youre focusing.
- Step 5 summary and finished deck views now surface the decks chosen themes (and commander hover metadata) without flooding the UI with every commander tag.
- Doctors 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

View file

@ -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/Doctors 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 |

View file

@ -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 / Doctors 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/Doctors 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 |
| --- | --- | --- |

View file

@ -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/Doctors 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 Doctors 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, Doctors 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 2s 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.

View 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",
]

View file

@ -1054,26 +1054,40 @@ class DeckBuilder(
if self.commander_row is None:
raise RuntimeError("Commander must be selected before determining color identity.")
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:
raw_ci = self.commander_row.get('colorIdentity')
if isinstance(raw_ci, list):
colors_list = raw_ci
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("'[] ") for c in raw_ci.split(',') if c.strip().strip("'[] ")]
colors_list = [c.strip().strip("'[] ").upper() for c in raw_ci.split(',') if c.strip().strip("'[] ")]
else:
colors_list = [c for c in raw_ci if c.isalpha()]
colors_list = [c.upper() for c in raw_ci if c.isalpha()]
else:
# Fallback to 'colors' field or treat as colorless
alt = self.commander_row.get('colors')
if isinstance(alt, list):
colors_list = alt
colors_list = [str(c).strip().upper() for c in alt]
elif isinstance(alt, str) and alt.strip():
colors_list = [c for c in alt if c.isalpha()]
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:

View file

@ -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'
]

View 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))

View 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",
]

View 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)

View 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()

View file

@ -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,8 +1024,11 @@ 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:
if suffix_padding:
w.writerow(data_row + suffix_padding)
else:
w.writerow(data_row)
self.output_func(f"Deck exported to {fname}")
@ -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

View file

@ -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)

View 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

View file

@ -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"),
}

View file

@ -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.

View file

@ -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,7 +163,11 @@ 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')

View file

@ -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)
@ -277,6 +286,27 @@ def run(
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:
builder.include_cards = list(include_cards or []) # type: ignore[attr-defined]
@ -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))

View 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())

View 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()

View file

@ -250,6 +250,10 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
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)
print('\n====================\n')
@ -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']
}
])

View 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

View 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

View 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")

View file

@ -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")

View file

@ -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", [])
)

View 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"]

View file

@ -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__])

View 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"

View 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

View 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

View 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

View 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"

View 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

View 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

View 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)

View 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

View 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)

View 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"

View 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")

View file

@ -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

View file

@ -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:

View 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)

View file

@ -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

View file

@ -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:

View file

@ -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:

View 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()),
)

View file

@ -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)

View file

@ -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; }

View file

@ -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)

View file

@ -32,12 +32,16 @@
</fieldset>
<fieldset>
<legend>Themes</legend>
<div id="newdeck-tags-slot" class="muted">
<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 %}

View file

@ -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>
<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 %}
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
<div class="muted" style="font-size:12px;">Recommended</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 %}
<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-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 12 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>

View 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 ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[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 %}

View file

@ -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 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?">Why?</button>
<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" 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;">
<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" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
<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 %}
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
</div>
{% 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="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 ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[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,56 +341,72 @@
});
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);
});
// attach handlers to recommended chips and select-all
if (recoHost){
Array.prototype.forEach.call(recoHost.querySelectorAll('button.chip-reco'), function(btn){
chipHost.addEventListener('keydown', 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); });
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(_){ }
}
}
}
});
if (selAll){
selAll.addEventListener('click', function(){
if (recoHost){
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(_){ }
return;
}
if (btn.classList.contains('chip-reco')){
var t = btn.dataset.tag || '';
if (t){ toggleTag(t); }
}
});
}
// Why recommended panel toggle
var whyBtn = document.getElementById('reco-why');
var whyPanel = document.getElementById('reco-why-panel');
function setWhy(open){
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';
setWhy(!isOpen);
toggleWhyPanel(!isOpen);
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){ } }
});
document.addEventListener('click', function(e){
@ -288,34 +414,123 @@
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
if (!isOpen) return;
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
setWhy(false);
toggleWhyPanel(false);
} catch(_){ }
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape'){ setWhy(false); }
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();

View file

@ -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,7 +31,7 @@
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 %}>
@ -18,7 +40,7 @@
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;">

View file

@ -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>

View file

@ -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 ({"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;"}[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');

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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: