From d416c9b2381d3d8d14127bc823595ecc28245315 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 6 Oct 2025 09:17:59 -0700 Subject: [PATCH] feat: Added Partners, Backgrounds, and related variation selections to commander building. --- .env.example | 7 + CHANGELOG.md | 50 +- DOCKER.md | 9 + README.md | 17 +- RELEASE_NOTES_TEMPLATE.md | 33 +- code/deck_builder/background_loader.py | 262 +++ code/deck_builder/builder.py | 56 +- code/deck_builder/builder_constants.py | 2 +- code/deck_builder/color_identity_utils.py | 134 ++ code/deck_builder/combined_commander.py | 325 ++++ code/deck_builder/partner_background_utils.py | 287 ++++ code/deck_builder/partner_selection.py | 426 +++++ code/deck_builder/phases/phase6_reporting.py | 274 ++- code/deck_builder/random_entrypoint.py | 22 + code/deck_builder/suggestions.py | 662 ++++++++ code/deck_builder/summary_telemetry.py | 73 + code/exceptions.py | 8 + code/file_setup/setup.py | 29 +- code/headless_runner.py | 144 +- code/scripts/build_partner_suggestions.py | 767 +++++++++ code/scripts/generate_background_cards.py | 159 ++ code/tagging/tagger.py | 87 +- code/tests/test_background_loader.py | 62 + code/tests/test_cli_partner_config.py | 147 ++ code/tests/test_combined_commander.py | 254 +++ .../test_commander_primary_face_filter.py | 67 +- code/tests/test_diagnostics.py | 50 + code/tests/test_export_commander_metadata.py | 110 ++ .../tests/test_include_exclude_persistence.py | 39 +- .../test_orchestrator_partner_helpers.py | 36 + code/tests/test_partner_background_utils.py | 162 ++ code/tests/test_partner_option_filtering.py | 133 ++ code/tests/test_partner_scoring.py | 293 ++++ code/tests/test_partner_selection.py | 324 ++++ code/tests/test_partner_suggestions_api.py | 304 ++++ .../test_partner_suggestions_pipeline.py | 163 ++ .../tests/test_partner_suggestions_service.py | 133 ++ .../test_partner_suggestions_telemetry.py | 98 ++ code/tests/test_partner_synergy_refresh.py | 91 + code/tests/test_web_background_fallback.py | 27 + code/tests/test_web_new_deck_partner.py | 299 ++++ code/web/app.py | 20 +- code/web/routes/build.py | 1224 +++++++++++++- code/web/routes/commanders.py | 4 +- code/web/routes/partner_suggestions.py | 160 ++ code/web/services/build_utils.py | 68 + code/web/services/commander_catalog_loader.py | 125 +- code/web/services/orchestrator.py | 296 ++++ code/web/services/partner_suggestions.py | 595 +++++++ code/web/services/telemetry.py | 115 +- code/web/static/styles.css | 12 + code/web/templates/base.html | 8 +- code/web/templates/build/_new_deck_modal.html | 16 +- code/web/templates/build/_new_deck_tags.html | 158 +- .../templates/build/_partner_controls.html | 959 +++++++++++ code/web/templates/build/_step2.html | 371 ++++- code/web/templates/build/_step5.html | 100 +- code/web/templates/decks/view.html | 10 +- code/web/templates/diagnostics/index.html | 161 ++ config/deck.json | 3 + config/themes/theme_list.json | 1482 ++++++++++++----- config/themes/theme_whitelist.yml | 2 + docker-compose.yml | 6 +- dockerhub-docker-compose.yml | 5 +- docs/headless_cli_guide.md | 1 + 65 files changed, 11835 insertions(+), 691 deletions(-) create mode 100644 code/deck_builder/background_loader.py create mode 100644 code/deck_builder/color_identity_utils.py create mode 100644 code/deck_builder/combined_commander.py create mode 100644 code/deck_builder/partner_background_utils.py create mode 100644 code/deck_builder/partner_selection.py create mode 100644 code/deck_builder/suggestions.py create mode 100644 code/scripts/build_partner_suggestions.py create mode 100644 code/scripts/generate_background_cards.py create mode 100644 code/tests/test_background_loader.py create mode 100644 code/tests/test_cli_partner_config.py create mode 100644 code/tests/test_combined_commander.py create mode 100644 code/tests/test_export_commander_metadata.py create mode 100644 code/tests/test_orchestrator_partner_helpers.py create mode 100644 code/tests/test_partner_background_utils.py create mode 100644 code/tests/test_partner_option_filtering.py create mode 100644 code/tests/test_partner_scoring.py create mode 100644 code/tests/test_partner_selection.py create mode 100644 code/tests/test_partner_suggestions_api.py create mode 100644 code/tests/test_partner_suggestions_pipeline.py create mode 100644 code/tests/test_partner_suggestions_service.py create mode 100644 code/tests/test_partner_suggestions_telemetry.py create mode 100644 code/tests/test_partner_synergy_refresh.py create mode 100644 code/tests/test_web_background_fallback.py create mode 100644 code/tests/test_web_new_deck_partner.py create mode 100644 code/web/routes/partner_suggestions.py create mode 100644 code/web/services/partner_suggestions.py create mode 100644 code/web/templates/build/_partner_controls.html diff --git a/.env.example b/.env.example index 54fab46..4e145e5 100644 --- a/.env.example +++ b/.env.example @@ -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) ############################ diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ba195..8cefcb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,55 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -- _TBD_ +- Partner suggestion service and API power Step 2 suggestion chips for partner, background, and Doctor pairings when `ENABLE_PARTNER_SUGGESTIONS` is active. +- Headless runner now honors partner/background inputs behind the `ENABLE_PARTNER_MECHANICS` feature flag and carries regression coverage for dry-run resolution. +- Web builder Step 2 exposes partner/background pairing when `ENABLE_PARTNER_MECHANICS` is active, including live previews and warnings for invalid combinations. +- Quick-start modal mirrors the Step 2 partner/background controls so fast deck builds can choose a secondary commander or background without leaving the modal. +- Partner mechanics UI auto-enables for eligible commanders, renames the secondary picker to “Partner commander,” layers in Partner With defaults with opt-out chips, adds Doctor/Doctor’s Companion pairing, and keeps modal/theme previews in sync. +- Deck exports now surface combined commander metadata across CSV/TXT headers and JSON summaries so dual-command builds stay in sync for downstream tooling. + +### Added +- Partner suggestion dataset loader, scoring service (`code/web/services/partner_suggestions.py`), FastAPI endpoint, UI chips, dataset override env (`PARTNER_SUGGESTIONS_DATASET`), auto-regeneration when the dataset is missing, and tests covering dataset flattening plus API responses. +- CLI regression coverage (`code/tests/test_cli_partner_config.py`) verifying partner/background dry-run payloads and `ENABLE_PARTNER_MECHANICS` env gating in the headless runner. +- Web build wizard toggle for partner mechanics with partner/background selectors, auto-pair hints, warnings, and combined color preview behind the feature flag. +- Partner and background selections now render card art previews (with Scryfall links) in the quick-start wizard, Step 2 form, and deck summary so builders can confirm the secondary pick at a glance. +- Quick-start modal now renders shared partner/background controls (reusing `_partner_controls.html`) whenever a commander that supports the mechanic is inspected. +- Background catalog loader (`code/deck_builder/background_loader.py`) with memoized parsing, typed entries, and a generator utility (`python -m code.scripts.generate_background_cards`) plus coverage to ensure only legal backgrounds enter the catalog. +- Shared `CombinedCommander` aggregation and partner/background selection helper wired through deck builds, exports, and partner preview endpoints with accompanying regression tests. +- Script `python -m code.scripts.build_partner_suggestions` materializes commander metadata, theme indexes, and observed pairings into `config/analytics/partner_synergy.json` to seed the partner suggestion engine. +- Partner suggestion scoring helper (`code/deck_builder/suggestions.py`) with mode-specific weights and regression tests ensuring canonical pairings rank highest across partner, background, and Doctor flows. +- Export regression coverage (`code/tests/test_export_commander_metadata.py`) verifies commander metadata is embedded in CSV/TXT headers and summary payloads while preserving existing columns. +- Partner suggestion telemetry emits `partner_suggestions.generated` and `partner_suggestions.selected` logs (via `code/web/services/telemetry.py`) so adoption metrics and dataset diagnostics can be monitored. + +### Changed +- Partner controls hydrate suggestion chips on the web builder and quick-start modal, fetching ranked partner/backdrop recommendations while respecting active partner mode and session locks when `ENABLE_PARTNER_SUGGESTIONS=1`. +- Partner suggestion scoring now filters out broad "Legends Matter", "Historics Matter", and Kindred themes when computing overlap or synergy so recommendations emphasize distinctive commander pairings. +- Headless runner parsing now resolves `--secondary-commander` and `--background` inputs (mutually exclusive), applies the shared partner selection helper ahead of deck assembly, and surfaces flag-controlled behavior in exported dry-run payloads. +- Step 2 submission now validates partner inputs, stores combined commander previews/warnings in the session, and clears prior partner state when the toggle is disabled. +- Quick-start `/build/new` submission resolves partner selections, persists the combined commander payload, and re-renders the modal with inline partner errors when inputs conflict. +- Partner controls mount automatically for eligible commanders, replace the manual toggle with a hidden enable flag, rename the select to “Partner commander,” and expose an opt-out chip when Partner With suggests a default. +- Commander catalog metadata now flags Doctors and Doctor’s Companions so selectors present only legal pairings and annotate each option with its role. +- Partner detection now distinguishes the standalone “Partner” keyword from Partner With/Doctor’s Companion/restricted variants, and the web selector filters plain-partner pools to exclude those mechanics while keeping direct Partner With pairings intact. +- Structured partner selection logs now emit `partner_mode_selected` with commander color deltas, capturing colors before and after pairing for diagnostics parity. +- Structured partner selection logs now tag suggestion-driven selections with a `selection_source` attribute to differentiate manual picks from suggestion chip adoption. +- Commander setup now regenerates `background_cards.csv` alongside `commander_cards.csv`, ensuring the background picker stays synchronized after catalog refreshes or fresh installs. +- Setup/tagging auto-refresh now runs the partner suggestion dataset builder so `config/analytics/partner_synergy.json` tracks the latest commander catalog and deck exports without manual scripts. +- CSV/TXT deck exports append commander metadata columns, text headers include partner mode and colors, and summary sidecars embed serialized combined commander details without breaking legacy consumers. +- Partner commander previews in Step 2 and the build summary now mirror the primary commander card layout (including hover metadata and high-res art) so both selections share identical interactions. + +### Fixed +- Regenerated `background_cards.csv` and tightened background detection so the picker only lists true Background enchantments, preventing "Choose a Background" commanders from appearing as illegal partners and restoring background availability when the CSV was missing. +- Restricted partner commanders with dash-based keywords (e.g., Partner - Survivors, Partner - Father & Son) now register as partners and surface their matching group pairings in the web selector. +- Quick-start modal partner previews now merge theme tags with Step 2 so chips stay consistent after commander inspection. +- Step 5 summary and quick-start commander preview now surface merged partner color identity and theme tags so pairings like Halana + Alena display both colors. +- Partner and background builds now inject the secondary commander card automatically, keeping deck libraries, exports, and Step 5 summaries in sync with the chosen pairing. +- Partner With commanders now restrict the dropdown to their canon companion and the preview panel adopts the wizard theme colors for better readability while live-selection previews render immediately. +- Manual partner selections now persist across the wizard and quick-start modal, keeping recommendations and theme chips in sync without needing an extra apply step. +- Background picker now falls back to the commander catalog when `background_cards.csv` is missing so “Choose a Background” commanders remain selectable in the web UI. +- Partner hover previews now respect the secondary commander data so the popup matches the card you’re focusing. +- Step 5 summary and finished deck views now surface the deck’s chosen themes (and commander hover metadata) without flooding the UI with every commander tag. +- Doctor’s Companion commanders now surface only legal Doctor pairings, direct Partner With matches (e.g., Amy & Rory) remain available, and escaped newline text no longer breaks partner detection. +- Partner suggestion refresh now re-attempts dataset generation when triggered from the UI and ensures the builder script loads project packages inside Docker, so missing `partner_synergy.json` files can be recreated without restarting the web app. ## [2.4.1] - 2025-10-03 ### Summary diff --git a/DOCKER.md b/DOCKER.md index f3bca3b..6b5185b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -127,6 +127,8 @@ docker compose run --rm ` - `APP_MODE=cli` routes the entrypoint to the CLI menu. - `DECK_MODE=headless` skips prompts and calls `headless_runner`. - Mount JSON configs under `config/` so both the UI and CLI can pick them up. +- Dual-commander support is feature-flagged: set `ENABLE_PARTNER_MECHANICS=1` and pass `--secondary-commander` _or_ `--background` (mutually exclusive) to layer partners/backgrounds into headless runs; Partner With and Doctor/Doctor’s Companion pairings auto-resolve (with opt-out), and `--dry-run` echoes the resolved pairing for verification. +- Partner suggestions share the same dataset for headless and web flows; set `ENABLE_PARTNER_SUGGESTIONS=1` (and ensure `config/analytics/partner_synergy.json` exists) to expose ranked pairings in the UI and API. Override counts, theme tags, or include/exclude lists by setting the matching environment variables before running the container (see “Environment variables” below). @@ -232,6 +234,13 @@ See `.env.example` for the full catalog. Common knobs: | `DECK_CONFIG` | `/app/config/deck.json` | JSON config file or directory (auto-discovery). | | `HOST` / `PORT` / `WORKERS` | `0.0.0.0` / `8080` / `1` | Uvicorn binding when `APP_MODE=web`. | +### Partner mechanics & suggestions + +| Variable | Default | Purpose | +| --- | --- | --- | +| `ENABLE_PARTNER_MECHANICS` | `0` | Unlock partner/background commander inputs for headless runs and Step 2 of the web UI. | +| `ENABLE_PARTNER_SUGGESTIONS` | `0` | Serve partner/background/Doctor suggestion chips based on `config/analytics/partner_synergy.json` (auto-regenerated when missing; override path with `PARTNER_SUGGESTIONS_DATASET`). | + ### Homepage visibility & UX | Variable | Default | Purpose | diff --git a/README.md b/README.md index 4b72ee0..4d30a5a 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,14 @@ Every tile on the homepage connects to a workflow. Use these sections as your to Start here for interactive deck creation. - Pick commander, themes (primary/secondary/tertiary), bracket, and optional deck name in the unified modal. - Add supplemental themes in the **Additional Themes** section (ENABLE_CUSTOM_THEMES): fuzzy suggestions, removable chips, and strict/permissive matching toggles respect `THEME_MATCH_MODE` and `USER_THEME_LIMIT`. +- Partner mechanics (ENABLE_PARTNER_MECHANICS): Step 2 and the quick-start modal auto-enable partner controls for eligible commanders, show only legal partner/background/Doctor options, and keep previews, warnings, and theme chips in sync. + - Partner suggestions (ENABLE_PARTNER_SUGGESTIONS): ranked chips appear beside the partner selector, recommending popular partner/background/Doctor pairings based on the analytics dataset; selections respect existing partner mode and lock states. + - Partner: pick a second commander from the filtered dropdown labeled “Partner commander”; the background picker clears automatically. + - Partner With: the canonical partner pre-fills and surfaces an opt-out chip so you can keep or swap the suggestion. + - Doctor / Doctor’s Companion: Doctors list legal companions (and vice versa) with role labels, and the opt-out chip mirrors Partner With behavior. + - Background: choose a Background instead of a second commander; partner selectors hide when not applicable. - Locks, Replace, Compare, and Permalinks live in Step 5. -- Exports (CSV, TXT, compliance JSON, summary JSON) land in `deck_files/` and reuse your chosen deck name when set. +- Exports (CSV, TXT, compliance JSON, summary JSON) land in `deck_files/` and reuse your chosen deck name when set. CSV/TXT headers now include commander metadata (names, partner mode, colors) so downstream tools can pick up dual-commander context without extra parsing. - `ALLOW_MUST_HAVES=1` (default) enables include/exclude enforcement. - `WEB_AUTO_ENFORCE=1` re-runs bracket enforcement automatically after each build. @@ -167,6 +173,7 @@ The CLI and headless runners share the builder core. - Run headless (non-interactive) builds: `python code/headless_runner.py --config config/deck.json`. - In Docker, set `APP_MODE=cli` (and optionally `DECK_MODE=headless`) to switch the container entrypoint to the CLI. - Config precedence is CLI prompts > environment variables > JSON config > defaults. +- Dual-commander support (feature-flagged): `--secondary-commander` or `--background` (mutually exclusive) can be supplied alongside `--enable-partner-mechanics true` or `ENABLE_PARTNER_MECHANICS=1`; Partner With and Doctor/Doctor’s Companion pairings auto-resolve (respecting opt-outs), dry runs echo the resolved pairing, and JSON configs may include `secondary_commander`, `background`, and `enable_partner_mechanics` keys. --- @@ -179,7 +186,7 @@ The CLI and headless runners share the builder core. | `config/` | `/app/config` | JSON configs, bracket policies, themes, card lists | | `owned_cards/` | `/app/owned_cards` | Uploaded owned-card libraries | -Exports follow a stable naming scheme and include a `.summary.json` sidecar containing deck metadata, resolved themes, and lock history. +Exports follow a stable naming scheme and include a `.summary.json` sidecar containing deck metadata, resolved themes, combined commander payloads, and lock history. --- @@ -194,6 +201,12 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl | `DECK_CONFIG` | `/app/config/deck.json` | Points the headless runner at a config file or folder. | | `HOST` / `PORT` / `WORKERS` | `0.0.0.0` / `8080` / `1` | Uvicorn settings for the web server. | +### Partner / Background mechanics (feature-flagged) +| Variable | Default | Purpose | +| --- | --- | --- | +| `ENABLE_PARTNER_MECHANICS` | `0` | Unlock partner/background commander inputs for headless runs and the web builder Step 2 UI. | +| `ENABLE_PARTNER_SUGGESTIONS` | `0` | Surface partner/background/Doctor suggestion chips backed by `config/analytics/partner_synergy.json` (auto-regenerated when missing; override path with `PARTNER_SUGGESTIONS_DATASET`). | + ### Homepage visibility & UX | Variable | Default | Purpose | | --- | --- | --- | diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 32e1bd5..ca07a65 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,13 +1,38 @@ # MTG Python Deckbuilder ${VERSION} ## Summary -- _TBD_ +- Partner suggestion service and UI chips recommend secondary commanders (partner/background/Doctor) when `ENABLE_PARTNER_SUGGESTIONS` is enabled. +- Headless runner now honors partner/background inputs behind the `ENABLE_PARTNER_MECHANICS` feature flag and exposes the resolved configuration in dry-run output. +- Web builder Step 2 displays partner/background pairing controls (toggle, selectors, preview, warnings) when the feature flag is active. +- Quick-start modal now embeds the shared partner/background controls so the rapid flow can choose secondary commanders or backgrounds without leaving the overlay. +- Partner mechanics UI auto-enables for eligible commanders, renames the selector to “Partner commander,” layers in Partner With defaults with an opt-out chip, and adds Doctor/Doctor’s Companion pairing coverage while keeping theme tags consistent across modal and Step 2. +- Background catalog parsing is now centralized in `load_background_cards()` with typed entries, memoized caching, and a generator utility so background-only card lists stay fresh. +- Commander setup now regenerates `background_cards.csv` whenever commander catalogs refresh, keeping background pickers aligned after setup or data updates. ## Added -- _TBD_ +- Partner suggestion dataset loader, FastAPI endpoint, UI wiring, dataset override env (`PARTNER_SUGGESTIONS_DATASET`), automatic regeneration when the dataset is missing, and regression coverage for ranked results when suggestions are enabled. +- CLI regression coverage (`code/tests/test_cli_partner_config.py`) validating partner/background dry-run payloads and environment flag precedence. +- Partner mechanics UI in the web builder (Step 2) with live preview, warnings, and automatic Partner With hints behind `ENABLE_PARTNER_MECHANICS`. +- Quick-start modal renders the `_partner_controls.html` partial, surfacing partner/background selections during commander inspection. +- Commander metadata now flags Doctors and Doctor’s Companions, enabling legal doctor/companion pairings in partner selectors with role-aware labels. +- New background catalog loader and `python -m code.scripts.generate_background_cards` utility, plus regression coverage ensuring only legal backgrounds populate the catalog. +- Shared `build_combined_commander()` aggregation and partner selection helper reused by headless, web, and orchestration flows with expanded unit coverage. ## Changed -- _TBD_ +- Partner controls now fetch suggestion chips in Step 2 and the quick-start modal (respecting partner mode and locks) when `ENABLE_PARTNER_SUGGESTIONS=1`. +- Partner suggestion scoring filters out broad "Legends Matter", "Historics Matter", and Kindred themes during overlap/synergy calculations so suggested pairings highlight distinctive commander synergies. +- Headless runner parsing resolves `--secondary-commander` and `--background` inputs (mutually exclusive), applies the partner selection helper before deck assembly, and surfaces partner metadata when the feature flag is enabled. +- Step 2 submission now validates partner selections, stores combined commander previews in session state, and clears partner context when the toggle is disabled. +- `/build/new` submission mirrors the partner validation/resolution flow, persisting combined commander payloads and returning inline partner errors when inputs conflict. +- Partner controls no longer rely on a manual checkbox; they render automatically for eligible commanders, rename the secondary selector to “Partner commander,” and expose a Partner With default chip that can be toggled off. +- Deck assembly, exports, and preview endpoints now consume the shared combined-commander payload so color identity, theme tags, and warnings stay aligned across flows. +- Partner detection differentiates between standalone “Partner” cards and restricted mechanics (Partner With, Doctor’s Companion, hyphenated variants), keeping plain-partner pools clean while retaining direct Partner With pairings. +- Structured partner selection logs now emit `partner_mode_selected` and include before/after color identity snapshots to support diagnostics and telemetry dashboards. +- Commander setup now regenerates the background catalog in the same pass as commander CSVs, so downstream pickers stay synchronized without manual scripts. ## Fixed -- _TBD_ +- Regenerated `background_cards.csv` and refined detection so only true Background enchantments appear in the dropdown, preventing "Choose a Background" commanders from showing up as illegal selections. +- Quick-start modal now mirrors Step 2’s merged theme tags so chips stay consistent after commander inspection. +- Step 5 summary and quick-start commander preview now surface merged partner color identity and theme tags so partnered commanders show the full color pair. +- Background picker falls back to the commander catalog when `background_cards.csv` is missing so “Choose a Background” commanders keep their pairing options in the web UI. +- Partner suggestions refresh actions now retry dataset generation and load the builder script with the correct project path, allowing missing `partner_synergy.json` files to be rebuilt without restarting the web service. diff --git a/code/deck_builder/background_loader.py b/code/deck_builder/background_loader.py new file mode 100644 index 0000000..87123d1 --- /dev/null +++ b/code/deck_builder/background_loader.py @@ -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", +] diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index 093ce48..a7a5d53 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -1054,26 +1054,40 @@ class DeckBuilder( if self.commander_row is None: raise RuntimeError("Commander must be selected before determining color identity.") - raw_ci = self.commander_row.get('colorIdentity') - if isinstance(raw_ci, list): - colors_list = raw_ci - elif isinstance(raw_ci, str) and raw_ci.strip(): - # Could be formatted like "['B','G']" or 'BG'; attempt simple parsing - if ',' in raw_ci: - colors_list = [c.strip().strip("'[] ") for c in raw_ci.split(',') if c.strip().strip("'[] ")] - else: - colors_list = [c for c in raw_ci if c.isalpha()] + override_identity = getattr(self, 'combined_color_identity', None) + colors_list: List[str] + if override_identity: + colors_list = [str(c).strip().upper() for c in override_identity if str(c).strip()] else: - # Fallback to 'colors' field or treat as colorless - alt = self.commander_row.get('colors') - if isinstance(alt, list): - colors_list = alt - elif isinstance(alt, str) and alt.strip(): - colors_list = [c for c in alt if c.isalpha()] + raw_ci = self.commander_row.get('colorIdentity') + if isinstance(raw_ci, list): + colors_list = [str(c).strip().upper() for c in raw_ci] + elif isinstance(raw_ci, str) and raw_ci.strip(): + # Could be formatted like "['B','G']" or 'BG'; attempt simple parsing + if ',' in raw_ci: + colors_list = [c.strip().strip("'[] ").upper() for c in raw_ci.split(',') if c.strip().strip("'[] ")] + else: + colors_list = [c.upper() for c in raw_ci if c.isalpha()] else: - colors_list = [] + # Fallback to 'colors' field or treat as colorless + alt = self.commander_row.get('colors') + if isinstance(alt, list): + colors_list = [str(c).strip().upper() for c in alt] + elif isinstance(alt, str) and alt.strip(): + colors_list = [c.upper() for c in alt if c.isalpha()] + else: + colors_list = [] - self.color_identity = [c.upper() for c in colors_list] + deduped: List[str] = [] + seen_tokens: set[str] = set() + for token in colors_list: + if not token: + continue + if token not in seen_tokens: + seen_tokens.add(token) + deduped.append(token) + + self.color_identity = deduped self.color_identity_key = self._canonical_color_key(self.color_identity) # Match against maps @@ -1097,6 +1111,14 @@ class DeckBuilder( self.color_identity_full = full self.files_to_load = load_files + + # Synchronize commander summary metadata when partner overrides are present + if override_identity and self.commander_dict: + try: + self.commander_dict["Color Identity"] = list(self.color_identity) + self.commander_dict["Colors"] = list(self.color_identity) + except Exception: + pass return full, load_files def setup_dataframes(self) -> pd.DataFrame: diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index ea2b449..d7cc810 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -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' ] diff --git a/code/deck_builder/color_identity_utils.py b/code/deck_builder/color_identity_utils.py new file mode 100644 index 0000000..0006753 --- /dev/null +++ b/code/deck_builder/color_identity_utils.py @@ -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)) diff --git a/code/deck_builder/combined_commander.py b/code/deck_builder/combined_commander.py new file mode 100644 index 0000000..a5694b6 --- /dev/null +++ b/code/deck_builder/combined_commander.py @@ -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", +] diff --git a/code/deck_builder/partner_background_utils.py b/code/deck_builder/partner_background_utils.py new file mode 100644 index 0000000..1703598 --- /dev/null +++ b/code/deck_builder/partner_background_utils.py @@ -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) diff --git a/code/deck_builder/partner_selection.py b/code/deck_builder/partner_selection.py new file mode 100644 index 0000000..f5808bc --- /dev/null +++ b/code/deck_builder/partner_selection.py @@ -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() diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 7b1df2b..c1fa136 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -7,7 +7,8 @@ import datetime as _dt import re as _re import logging_util -from code.deck_builder.summary_telemetry import record_land_summary, record_theme_summary +from code.deck_builder.summary_telemetry import record_land_summary, record_theme_summary, record_partner_summary +from code.deck_builder.color_identity_utils import normalize_colors, canon_color_code, color_label_from_code from code.deck_builder.shared_copy import build_land_headline, dfc_card_note logger = logging_util.logging.getLogger(__name__) @@ -27,6 +28,144 @@ class ReportingMixin: """Public method for orchestration: delegates to print_type_summary and print_card_library.""" self.print_type_summary() self.print_card_library(table=True) + + def get_commander_export_metadata(self) -> Dict[str, Any]: + """Return metadata describing the active commander configuration for export surfaces.""" + + def _clean(value: object) -> str: + try: + text = str(value).strip() + except Exception: + text = "" + return text + + metadata: Dict[str, Any] = { + "primary_commander": None, + "secondary_commander": None, + "commander_names": [], + "partner_mode": None, + "color_identity": [], + } + + combined = getattr(self, 'combined_commander', None) + + commander_names: list[str] = [] + primary_name = None + secondary_name = None + + if combined is not None: + primary_name = _clean(getattr(combined, 'primary_name', '')) or None + secondary_name = _clean(getattr(combined, 'secondary_name', '')) or None + partner_mode_obj = getattr(combined, 'partner_mode', None) + partner_mode_val = getattr(partner_mode_obj, 'value', None) + if isinstance(partner_mode_val, str) and partner_mode_val.strip(): + metadata["partner_mode"] = partner_mode_val.strip() + elif isinstance(partner_mode_obj, str) and partner_mode_obj.strip(): + metadata["partner_mode"] = partner_mode_obj.strip() + if primary_name: + commander_names.append(primary_name) + if secondary_name and all(secondary_name.casefold() != n.casefold() for n in commander_names): + commander_names.append(secondary_name) + combined_identity_raw = list(getattr(combined, 'color_identity', []) or []) + combined_colors = normalize_colors(combined_identity_raw) + primary_colors = normalize_colors(getattr(combined, 'primary_color_identity', ())) + secondary_colors = normalize_colors(getattr(combined, 'secondary_color_identity', ())) + color_code = getattr(combined, 'color_code', '') or canon_color_code(combined_identity_raw) + color_label = getattr(combined, 'color_label', '') or color_label_from_code(color_code) + + mode_lower = (metadata["partner_mode"] or "").lower() if metadata.get("partner_mode") else "" + if mode_lower == "background": + secondary_role = "background" + elif mode_lower == "doctor_companion": + secondary_role = "companion" + elif mode_lower == "partner_with": + secondary_role = "partner_with" + elif mode_lower == "partner": + secondary_role = "partner" + else: + secondary_role = "secondary" + + secondary_role_label_map = { + "background": "Background", + "companion": "Doctor pairing", + "partner_with": "Partner With", + "partner": "Partner commander", + } + secondary_role_label = secondary_role_label_map.get(secondary_role, "Partner commander") + + color_sources: list[Dict[str, Any]] = [] + for color in combined_colors: + providers: list[Dict[str, Any]] = [] + if primary_name and color in primary_colors: + providers.append({"name": primary_name, "role": "primary"}) + if secondary_name and color in secondary_colors: + providers.append({"name": secondary_name, "role": secondary_role}) + if not providers and primary_name: + providers.append({"name": primary_name, "role": "primary"}) + color_sources.append({"color": color, "providers": providers}) + + added_colors = [c for c in combined_colors if c not in primary_colors] + removed_colors = [c for c in primary_colors if c not in combined_colors] + + combined_payload = { + "primary_name": primary_name, + "secondary_name": secondary_name, + "partner_mode": metadata["partner_mode"], + "color_identity": combined_identity_raw, + "theme_tags": list(getattr(combined, 'theme_tags', []) or []), + "raw_tags_primary": list(getattr(combined, 'raw_tags_primary', []) or []), + "raw_tags_secondary": list(getattr(combined, 'raw_tags_secondary', []) or []), + "warnings": list(getattr(combined, 'warnings', []) or []), + "color_code": color_code, + "color_label": color_label, + "primary_color_identity": primary_colors, + "secondary_color_identity": secondary_colors, + "secondary_role": secondary_role, + "secondary_role_label": secondary_role_label, + "color_sources": color_sources, + "color_delta": { + "added": added_colors, + "removed": removed_colors, + "primary": primary_colors, + "secondary": secondary_colors, + }, + } + metadata["combined_commander"] = combined_payload + else: + primary_attr = _clean(getattr(self, 'commander_name', '') or getattr(self, 'commander', '')) + if primary_attr: + primary_name = primary_attr + commander_names.append(primary_attr) + secondary_attr = _clean(getattr(self, 'secondary_commander', '')) + if secondary_attr and all(secondary_attr.casefold() != n.casefold() for n in commander_names): + secondary_name = secondary_attr + commander_names.append(secondary_attr) + partner_mode_attr = getattr(self, 'partner_mode', None) + partner_mode_val = getattr(partner_mode_attr, 'value', None) + if isinstance(partner_mode_val, str) and partner_mode_val.strip(): + metadata["partner_mode"] = partner_mode_val.strip() + elif isinstance(partner_mode_attr, str) and partner_mode_attr.strip(): + metadata["partner_mode"] = partner_mode_attr.strip() + + metadata["primary_commander"] = primary_name + metadata["secondary_commander"] = secondary_name + metadata["commander_names"] = commander_names + + if metadata["partner_mode"]: + metadata["partner_mode"] = metadata["partner_mode"].lower() + + # Prefer combined color identity when available + color_source = None + if combined is not None: + color_source = getattr(combined, 'color_identity', None) + if not color_source: + color_source = getattr(self, 'combined_color_identity', None) + if not color_source: + color_source = getattr(self, 'color_identity', None) + if color_source: + metadata["color_identity"] = [str(c).strip().upper() for c in color_source if str(c).strip()] + + return metadata """Phase 6: Reporting, summaries, and export helpers.""" def enforce_and_reexport(self, base_stem: str | None = None, mode: str = "prompt") -> dict: @@ -623,6 +762,27 @@ class ReportingMixin: 'colors': list(getattr(self, 'color_identity', []) or []), 'include_exclude_summary': include_exclude_summary, } + + try: + commander_meta = self.get_commander_export_metadata() + except Exception: + commander_meta = {} + commander_names = commander_meta.get('commander_names') or [] + if commander_names: + summary_payload['commander'] = { + 'names': commander_names, + 'primary': commander_meta.get('primary_commander'), + 'secondary': commander_meta.get('secondary_commander'), + 'partner_mode': commander_meta.get('partner_mode'), + 'color_identity': commander_meta.get('color_identity') or list(getattr(self, 'color_identity', []) or []), + } + combined_payload = commander_meta.get('combined_commander') + if combined_payload: + summary_payload['commander']['combined'] = combined_payload + try: + record_partner_summary(summary_payload['commander']) + except Exception: # pragma: no cover - diagnostics only + logger.debug("Failed to record partner telemetry", exc_info=True) try: record_land_summary(land_summary) except Exception: # pragma: no cover - diagnostics only @@ -721,6 +881,17 @@ class ReportingMixin: "Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","Text","DFCNote","Owned" ] + header_suffix: List[str] = [] + try: + commander_meta = self.get_commander_export_metadata() + except Exception: + commander_meta = {} + commander_names = commander_meta.get('commander_names') or [] + if commander_names: + header_suffix.append(f"Commanders: {', '.join(commander_names)}") + header_row = headers + header_suffix + suffix_padding = [''] * len(header_suffix) + # Precedence list for sorting precedence_order = [ 'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land' @@ -853,9 +1024,12 @@ class ReportingMixin: with open(fname, 'w', newline='', encoding='utf-8') as f: w = csv.writer(f) - w.writerow(headers) + w.writerow(header_row) for _, data_row in rows: - w.writerow(data_row) + if suffix_padding: + w.writerow(data_row + suffix_padding) + else: + w.writerow(data_row) self.output_func(f"Deck exported to {fname}") # Auto-generate matching plaintext list (best-effort; ignore failures) @@ -979,7 +1153,24 @@ class ReportingMixin: sortable.append(((prec, name.lower()), name, info.get('Count',1), dfc_note)) sortable.sort(key=lambda x: x[0]) + try: + commander_meta = self.get_commander_export_metadata() + except Exception: + commander_meta = {} + header_lines: List[str] = [] + commander_names = commander_meta.get('commander_names') or [] + if commander_names: + header_lines.append(f"# Commanders: {', '.join(commander_names)}") + partner_mode = commander_meta.get('partner_mode') + if partner_mode and partner_mode not in (None, '', 'none'): + header_lines.append(f"# Partner Mode: {partner_mode}") + color_identity = commander_meta.get('color_identity') or [] + if color_identity: + header_lines.append(f"# Colors: {', '.join(color_identity)}") + with open(path, 'w', encoding='utf-8') as f: + if header_lines: + f.write("\n".join(header_lines) + "\n\n") for _, name, count, dfc_note in sortable: line = f"{count} {name}" if dfc_note: @@ -1001,6 +1192,9 @@ class ReportingMixin: - add_lands, add_creatures, add_non_creature_spells (defaults True) - fetch_count (if determined during run) - ideal_counts (the actual ideal composition values used) + - secondary_commander (when partner mechanics apply) + - background (when Choose a Background is used) + - enable_partner_mechanics flag (bool, default False) """ os.makedirs(directory, exist_ok=True) @@ -1019,6 +1213,26 @@ class ReportingMixin: return candidate i += 1 + def _clean_text(value: object | None) -> str | None: + if value is None: + return None + if isinstance(value, str): + text = value.strip() + if not text: + return None + if text.lower() == "none": + return None + return text + try: + text = str(value).strip() + except Exception: + return None + if not text: + return None + if text.lower() == "none": + return None + return text + if filename is None: # Prefer a custom export base when present; else commander/themes try: @@ -1059,6 +1273,57 @@ class ReportingMixin: ] theme_catalog_version = getattr(self, 'theme_catalog_version', None) + partner_enabled_flag = bool(getattr(self, 'partner_feature_enabled', False)) + requested_secondary = _clean_text(getattr(self, 'requested_secondary_commander', None)) + requested_background = _clean_text(getattr(self, 'requested_background', None)) + stored_secondary = _clean_text(getattr(self, 'secondary_commander', None)) + stored_background = _clean_text(getattr(self, 'background', None)) + + metadata: Dict[str, Any] = {} + try: + metadata_candidate = self.get_commander_export_metadata() + except Exception: + metadata_candidate = {} + if isinstance(metadata_candidate, dict): + metadata = metadata_candidate + + partner_mode = str(metadata.get("partner_mode") or "").strip().lower() if metadata else "" + metadata_secondary = _clean_text(metadata.get("secondary_commander")) if metadata else None + combined_secondary = None + combined_info = metadata.get("combined_commander") if metadata else None + if isinstance(combined_info, dict): + combined_secondary = _clean_text(combined_info.get("secondary_name")) + + if partner_mode and partner_mode not in {"none", ""}: + partner_enabled_flag = True if not partner_enabled_flag else partner_enabled_flag + + secondary_for_export = None + background_for_export = None + if partner_mode == "background": + background_for_export = ( + combined_secondary + or requested_background + or metadata_secondary + or stored_background + or stored_secondary + ) + else: + secondary_for_export = ( + combined_secondary + or requested_secondary + or metadata_secondary + or stored_secondary + ) + background_for_export = requested_background or stored_background + + secondary_for_export = _clean_text(secondary_for_export) + background_for_export = _clean_text(background_for_export) + + if partner_mode == "background": + secondary_for_export = None + + enable_partner_flag = bool(partner_enabled_flag) + payload = { "commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '', "primary_tag": getattr(self, 'primary_tag', None), @@ -1086,6 +1351,9 @@ class ReportingMixin: # CamelCase aliases for downstream consumers (web diagnostics, external tooling) "userThemes": user_themes, "themeCatalogVersion": theme_catalog_version, + "secondary_commander": secondary_for_export, + "background": background_for_export, + "enable_partner_mechanics": enable_partner_flag, # chosen fetch land count (others intentionally omitted for variance) "fetch_count": chosen_fetch, # actual ideal counts used for this run diff --git a/code/deck_builder/random_entrypoint.py b/code/deck_builder/random_entrypoint.py index 83d6f55..7030488 100644 --- a/code/deck_builder/random_entrypoint.py +++ b/code/deck_builder/random_entrypoint.py @@ -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) diff --git a/code/deck_builder/suggestions.py b/code/deck_builder/suggestions.py new file mode 100644 index 0000000..071159f --- /dev/null +++ b/code/deck_builder/suggestions.py @@ -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 diff --git a/code/deck_builder/summary_telemetry.py b/code/deck_builder/summary_telemetry.py index 1897d73..6afa02c 100644 --- a/code/deck_builder/summary_telemetry.py +++ b/code/deck_builder/summary_telemetry.py @@ -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"), + } diff --git a/code/exceptions.py b/code/exceptions.py index 16737ba..5518ccb 100644 --- a/code/exceptions.py +++ b/code/exceptions.py @@ -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. diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py index f75381b..db6ad82 100644 --- a/code/file_setup/setup.py +++ b/code/file_setup/setup.py @@ -46,6 +46,27 @@ from exceptions import ( CommanderValidationError, MTGJSONDownloadError ) +from scripts import generate_background_cards as background_cards_script +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _generate_background_catalog(cards_path: str, output_path: str) -> None: + """Regenerate ``background_cards.csv`` from the latest cards dataset.""" + + logger.info('Generating background cards catalog') + args = [ + '--source', cards_path, + '--output', output_path, + ] + try: + background_cards_script.main(args) + except Exception: # pragma: no cover - surfaced to caller/test + logger.exception('Failed to generate background catalog') + raise + else: + logger.info('Background cards catalog generated successfully') # Create logger for this module logger = logging_util.logging.getLogger(__name__) @@ -142,8 +163,12 @@ def determine_commanders() -> None: # Save commander cards logger.info('Saving validated commander cards') - filtered_df.to_csv(f'{CSV_DIRECTORY}/commander_cards.csv', index=False) - + commander_path = f'{CSV_DIRECTORY}/commander_cards.csv' + filtered_df.to_csv(commander_path, index=False) + + background_output = f'{CSV_DIRECTORY}/background_cards.csv' + _generate_background_catalog(cards_file, background_output) + logger.info('Commander card generation completed successfully') except (CSVFileNotFoundError, MTGJSONDownloadError) as e: diff --git a/code/headless_runner.py b/code/headless_runner.py index 1c8dd2c..66f39d9 100644 --- a/code/headless_runner.py +++ b/code/headless_runner.py @@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple from deck_builder.builder import DeckBuilder from deck_builder import builder_constants as bc +from deck_builder.partner_selection import apply_partner_inputs from deck_builder.theme_resolution import ( ThemeResolutionInfo, clean_theme_inputs, @@ -205,8 +206,16 @@ def run( theme_match_mode: str = "permissive", user_theme_resolution: Optional[ThemeResolutionInfo] = None, user_theme_weight: Optional[float] = None, + secondary_commander: Optional[str] = None, + background: Optional[str] = None, + enable_partner_mechanics: bool = False, ) -> DeckBuilder: - """Run a scripted non-interactive deck build and return the DeckBuilder instance.""" + """Run a scripted non-interactive deck build and return the DeckBuilder instance. + + When ``enable_partner_mechanics`` is True, optional ``secondary_commander`` + or ``background`` inputs are resolved into a combined commander pairing + before any deck-building steps execute. + """ trimmed_commander = (command_name or "").strip() if trimmed_commander: _validate_commander_available(trimmed_commander) @@ -276,6 +285,27 @@ def run( builder.headless = True # type: ignore[attr-defined] except Exception: pass + + partner_feature_enabled = bool(enable_partner_mechanics) + secondary_clean = (secondary_commander or "").strip() + background_clean = (background or "").strip() + try: + builder.partner_feature_enabled = partner_feature_enabled # type: ignore[attr-defined] + builder.requested_secondary_commander = secondary_clean or None # type: ignore[attr-defined] + builder.requested_background = background_clean or None # type: ignore[attr-defined] + except Exception: + pass + + if partner_feature_enabled and trimmed_commander: + combined_result = apply_partner_inputs( + builder, + primary_name=trimmed_commander, + secondary_name=secondary_clean or None, + background_name=background_clean or None, + feature_enabled=True, + ) + if combined_result is not None: + _apply_combined_commander_to_builder(builder, combined_result) # Configure include/exclude settings (M1: Config + Validation + Persistence) try: @@ -480,6 +510,39 @@ def _print_include_exclude_summary(builder: DeckBuilder) -> None: print("=" * 50) +def _apply_combined_commander_to_builder(builder: DeckBuilder, combined_commander: Any) -> None: + """Attach combined commander metadata to the builder for downstream use.""" + + try: + builder.combined_commander = combined_commander # type: ignore[attr-defined] + except Exception: + pass + + try: + builder.partner_mode = combined_commander.partner_mode # type: ignore[attr-defined] + except Exception: + pass + + try: + builder.secondary_commander = combined_commander.secondary_name # type: ignore[attr-defined] + except Exception: + pass + + try: + builder.combined_color_identity = combined_commander.color_identity # type: ignore[attr-defined] + builder.combined_theme_tags = combined_commander.theme_tags # type: ignore[attr-defined] + builder.partner_warnings = combined_commander.warnings # type: ignore[attr-defined] + except Exception: + pass + + commander_dict = getattr(builder, "commander_dict", None) + if isinstance(commander_dict, dict): + try: + commander_dict["Partner Mode"] = combined_commander.partner_mode.value + commander_dict["Secondary Commander"] = combined_commander.secondary_name + except Exception: + pass + def _export_outputs(builder: DeckBuilder) -> None: # M4: Print include/exclude summary to console _print_include_exclude_summary(builder) @@ -550,6 +613,13 @@ def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]: return None +def _parse_bool_cli(val: str) -> bool: + result = _parse_bool(val) + if result is None: + raise argparse.ArgumentTypeError(f"Expected a boolean value, received '{val}'") + return result + + def _parse_card_list(val: Optional[str]) -> List[str]: """Parse comma or semicolon-separated card list from CLI argument.""" if not val: @@ -1166,6 +1236,12 @@ def _build_arg_parser() -> argparse.ArgumentParser: help="Path to JSON config file (string)") p.add_argument("--commander", metavar="NAME", default=None, help="Commander name to search for (string)") + p.add_argument("--secondary-commander", metavar="NAME", default=None, + help="Secondary commander name when using Partner/Partner With mechanics") + p.add_argument("--background", metavar="NAME", default=None, + help="Background card name when choosing a Background") + p.add_argument("--enable-partner-mechanics", metavar="BOOL", type=_parse_bool_cli, default=None, + help="Enable partner/background mechanics for this run (bool: true/false/1/0)") p.add_argument("--primary-choice", metavar="INT", type=int, default=None, help="Primary theme tag choice number (integer)") p.add_argument("--secondary-choice", metavar="INT", type=_parse_opt_int, default=None, @@ -1397,6 +1473,49 @@ def _resolve_value( return default +def _resolve_string_option( + cli_value: Optional[str], env_name: str, json_data: Dict[str, Any], json_key: str +) -> Optional[str]: + if cli_value is not None: + text = str(cli_value).strip() + return text or None + + env_val = os.getenv(env_name) + if env_val: + text = env_val.strip() + if text: + return text + + raw = json_data.get(json_key) + if raw is not None: + text = str(raw).strip() + if text: + return text + return None + + +def _resolve_bool_option( + cli_value: Optional[bool], env_name: str, json_data: Dict[str, Any], json_key: str +) -> Optional[bool]: + if cli_value is not None: + return bool(cli_value) + + env_val = os.getenv(env_name) + if env_val is not None: + parsed = _parse_bool(env_val) + if parsed is not None: + return parsed + + raw = json_data.get(json_key) + if raw is not None: + if isinstance(raw, bool): + return raw + parsed = _parse_bool(str(raw)) + if parsed is not None: + return parsed + return None + + def _main() -> int: _ensure_data_ready() parser = _build_arg_parser() @@ -1643,6 +1762,25 @@ def _main() -> int: print(str(exc)) return 2 + resolved_secondary_commander = _resolve_string_option( + getattr(args, "secondary_commander", None), + "DECK_SECONDARY_COMMANDER", + json_cfg, + "secondary_commander", + ) + resolved_background = _resolve_string_option( + getattr(args, "background", None), + "DECK_BACKGROUND", + json_cfg, + "background", + ) + resolved_partner_flag = _resolve_bool_option( + getattr(args, "enable_partner_mechanics", None), + "ENABLE_PARTNER_MECHANICS", + json_cfg, + "enable_partner_mechanics", + ) + resolved = { "command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]), "add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]), @@ -1671,6 +1809,9 @@ def _main() -> int: "additional_themes": list(theme_resolution.requested), "theme_match_mode": theme_resolution.mode, "user_theme_weight": weight_value, + "secondary_commander": resolved_secondary_commander, + "background": resolved_background, + "enable_partner_mechanics": bool(resolved_partner_flag) if resolved_partner_flag is not None else False, } if args.dry_run: @@ -1706,6 +1847,7 @@ def _main() -> int: try: run_kwargs = dict(resolved) run_kwargs["user_theme_resolution"] = theme_resolution + run_kwargs["enable_partner_mechanics"] = bool(resolved_partner_flag) run(**run_kwargs) except CommanderValidationError as exc: print(str(exc)) diff --git a/code/scripts/build_partner_suggestions.py b/code/scripts/build_partner_suggestions.py new file mode 100644 index 0000000..190ed28 --- /dev/null +++ b/code/scripts/build_partner_suggestions.py @@ -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()) diff --git a/code/scripts/generate_background_cards.py b/code/scripts/generate_background_cards.py new file mode 100644 index 0000000..0473ee4 --- /dev/null +++ b/code/scripts/generate_background_cards.py @@ -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() diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index 1051b3c..6d3c21e 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -249,6 +249,10 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: print('\n====================\n') tag_for_keywords(df, color) print('\n====================\n') + + ## Tag for partner effects + tag_for_partner_effects(df, color) + print('\n====================\n') ## Tag for various effects tag_for_cost_reduction(df, color) @@ -591,10 +595,27 @@ def tag_for_keywords(df: pd.DataFrame, color: str) -> None: if has_keywords.any(): # Vectorized split and merge into themeTags keywords_df = df.loc[has_keywords, ['themeTags', 'keywords']].copy() - df.loc[has_keywords, 'themeTags'] = keywords_df.apply( - lambda r: sorted(list(set((r['themeTags'] if isinstance(r['themeTags'], list) else []) + (r['keywords'].split(', ') if isinstance(r['keywords'], str) else [])))), - axis=1 - ) + exclusion_keywords = {'partner'} + + def _merge_keywords(row: pd.Series) -> list[str]: + base_tags = row['themeTags'] if isinstance(row['themeTags'], list) else [] + keywords_raw = row['keywords'] + + if isinstance(keywords_raw, str): + keywords_iterable = [part.strip() for part in keywords_raw.split(',')] + elif isinstance(keywords_raw, (list, tuple, set)): + keywords_iterable = [str(part).strip() for part in keywords_raw] + else: + keywords_iterable = [] + + filtered_keywords = [ + kw for kw in keywords_iterable + if kw and kw.lower() not in exclusion_keywords + ] + + return sorted(list(set(base_tags + filtered_keywords))) + + df.loc[has_keywords, 'themeTags'] = keywords_df.apply(_merge_keywords, axis=1) duration = (pd.Timestamp.now() - start_time).total_seconds() logger.info('Tagged %d cards with keywords in %.2f seconds', has_keywords.sum(), duration) @@ -616,6 +637,56 @@ def sort_theme_tags(df, color): logger.info(f'Theme tags alphabetically sorted in {color}_cards.csv.') return df.reindex(columns=available) +### Partner Mechanics +def tag_for_partner_effects(df: pd.DataFrame, color: str) -> None: + """Tag cards for partner-related keywords. + + Looks for 'partner', 'partner with', and permutations in rules text and + applies tags accordingly. + """ + logger.info(f'Tagging Partner keywords in {color}_cards.csv') + start_time = pd.Timestamp.now() + + try: + rules = [] + partner_mask = tag_utils.create_text_mask(df, r"\bpartner\b(?!\s*(?:with|[-—–]))") + if partner_mask.any(): + rules.append({ 'mask': partner_mask, 'tags': ['Partner'] }) + + partner_with_mask = tag_utils.create_text_mask(df, 'partner with') + if partner_with_mask.any(): + rules.append({ 'mask': partner_with_mask, 'tags': ['Partner with'] }) + + partner_survivors_mask = tag_utils.create_text_mask(df, r"Partner\s*[-—–]\s*Survivors") + if partner_survivors_mask.any(): + rules.append({ 'mask': partner_survivors_mask, 'tags': ['Partner - Survivors'] }) + + partner_father_and_son = tag_utils.create_text_mask(df, r"Partner\s*[-—–]\s*Father\s*&\s*Son") + if partner_father_and_son.any(): + rules.append({ 'mask': partner_father_and_son, 'tags': ['Partner - Father & Son'] }) + + friends_forever_mask = tag_utils.create_text_mask(df, 'Friends forever') + if friends_forever_mask.any(): + rules.append({ 'mask': friends_forever_mask, 'tags': ['Friends Forever'] }) + + doctors_companion_mask = tag_utils.create_text_mask(df, "Doctor's companion") + if doctors_companion_mask.any(): + rules.append({ 'mask': doctors_companion_mask, 'tags': ["Doctor's Companion"] }) + + if rules: + tag_utils.apply_rules(df, rules) + total = sum(int(r['mask'].sum()) for r in rules) + logger.info('Tagged %d cards with Partner keywords', total) + else: + logger.info('No Partner keywords found') + + duration = (pd.Timestamp.now() - start_time).total_seconds() + logger.info('Completed Bending tagging in %.2fs', duration) + + except Exception as e: + logger.error(f'Error tagging Bending keywords: {str(e)}') + raise + ### Cost reductions def tag_for_cost_reduction(df: pd.DataFrame, color: str) -> None: """Tag cards that reduce spell costs using vectorized operations. @@ -4677,7 +4748,11 @@ def tag_for_bending(df: pd.DataFrame, color: str) -> None: earth_mask = tag_utils.create_text_mask(df, 'earthbend') if earth_mask.any(): - rules.append({ 'mask': earth_mask, 'tags': ['Earthbend', 'Lands Matter', 'Landfall'] }) + rules.append({ 'mask': earth_mask, 'tags': ['Earthbending', 'Lands Matter', 'Landfall'] }) + + bending_mask = air_mask | water_mask | fire_mask | earth_mask + if bending_mask.any(): + rules.append({ 'mask': bending_mask, 'tags': ['Bending'] }) if rules: tag_utils.apply_rules(df, rules) @@ -5827,7 +5902,7 @@ def tag_for_planeswalkers(df: pd.DataFrame, color: str) -> None: tag_utils.apply_rules(df, rules=[ { 'mask': final_mask, - 'tags': ['Planeswalkers', 'Super Friends'] + 'tags': ['Planeswalkers', 'Superfriends'] } ]) diff --git a/code/tests/test_background_loader.py b/code/tests/test_background_loader.py new file mode 100644 index 0000000..45e75a9 --- /dev/null +++ b/code/tests/test_background_loader.py @@ -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 diff --git a/code/tests/test_cli_partner_config.py b/code/tests/test_cli_partner_config.py new file mode 100644 index 0000000..6196210 --- /dev/null +++ b/code/tests/test_cli_partner_config.py @@ -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 \ No newline at end of file diff --git a/code/tests/test_combined_commander.py b/code/tests/test_combined_commander.py new file mode 100644 index 0000000..addc866 --- /dev/null +++ b/code/tests/test_combined_commander.py @@ -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") \ No newline at end of file diff --git a/code/tests/test_commander_primary_face_filter.py b/code/tests/test_commander_primary_face_filter.py index 461d631..78904f9 100644 --- a/code/tests/test_commander_primary_face_filter.py +++ b/code/tests/test_commander_primary_face_filter.py @@ -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") diff --git a/code/tests/test_diagnostics.py b/code/tests/test_diagnostics.py index aad58c0..4d38a2b 100644 --- a/code/tests/test_diagnostics.py +++ b/code/tests/test_diagnostics.py @@ -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 '= 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", []) + ) diff --git a/code/tests/test_export_commander_metadata.py b/code/tests/test_export_commander_metadata.py new file mode 100644 index 0000000..db329a6 --- /dev/null +++ b/code/tests/test_export_commander_metadata.py @@ -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"] diff --git a/code/tests/test_include_exclude_persistence.py b/code/tests/test_include_exclude_persistence.py index 5520194..c75bb5c 100644 --- a/code/tests/test_include_exclude_persistence.py +++ b/code/tests/test_include_exclude_persistence.py @@ -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__]) diff --git a/code/tests/test_orchestrator_partner_helpers.py b/code/tests/test_orchestrator_partner_helpers.py new file mode 100644 index 0000000..f34f40f --- /dev/null +++ b/code/tests/test_orchestrator_partner_helpers.py @@ -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" diff --git a/code/tests/test_partner_background_utils.py b/code/tests/test_partner_background_utils.py new file mode 100644 index 0000000..98b20b1 --- /dev/null +++ b/code/tests/test_partner_background_utils.py @@ -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 \ No newline at end of file diff --git a/code/tests/test_partner_option_filtering.py b/code/tests/test_partner_option_filtering.py new file mode 100644 index 0000000..e4dd258 --- /dev/null +++ b/code/tests/test_partner_option_filtering.py @@ -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 diff --git a/code/tests/test_partner_scoring.py b/code/tests/test_partner_scoring.py new file mode 100644 index 0000000..c01d688 --- /dev/null +++ b/code/tests/test_partner_scoring.py @@ -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 diff --git a/code/tests/test_partner_selection.py b/code/tests/test_partner_selection.py new file mode 100644 index 0000000..ffea5bf --- /dev/null +++ b/code/tests/test_partner_selection.py @@ -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" \ No newline at end of file diff --git a/code/tests/test_partner_suggestions_api.py b/code/tests/test_partner_suggestions_api.py new file mode 100644 index 0000000..a54838f --- /dev/null +++ b/code/tests/test_partner_suggestions_api.py @@ -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 diff --git a/code/tests/test_partner_suggestions_pipeline.py b/code/tests/test_partner_suggestions_pipeline.py new file mode 100644 index 0000000..25c132f --- /dev/null +++ b/code/tests/test_partner_suggestions_pipeline.py @@ -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 diff --git a/code/tests/test_partner_suggestions_service.py b/code/tests/test_partner_suggestions_service.py new file mode 100644 index 0000000..406ca0a --- /dev/null +++ b/code/tests/test_partner_suggestions_service.py @@ -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) diff --git a/code/tests/test_partner_suggestions_telemetry.py b/code/tests/test_partner_suggestions_telemetry.py new file mode 100644 index 0000000..1ebd404 --- /dev/null +++ b/code/tests/test_partner_suggestions_telemetry.py @@ -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 \ No newline at end of file diff --git a/code/tests/test_partner_synergy_refresh.py b/code/tests/test_partner_synergy_refresh.py new file mode 100644 index 0000000..cf3c2e1 --- /dev/null +++ b/code/tests/test_partner_synergy_refresh.py @@ -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) diff --git a/code/tests/test_web_background_fallback.py b/code/tests/test_web_background_fallback.py new file mode 100644 index 0000000..e63cc79 --- /dev/null +++ b/code/tests/test_web_background_fallback.py @@ -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" diff --git a/code/tests/test_web_new_deck_partner.py b/code/tests/test_web_new_deck_partner.py new file mode 100644 index 0000000..703dd9f --- /dev/null +++ b/code/tests/test_web_new_deck_partner.py @@ -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'
Themes -
- Select a commander to see theme recommendations and choices. - - - - +
+ {% if tag_slot_html %} + {{ tag_slot_html | safe }} + {% else %} + Select a commander to see theme recommendations and choices. + + + + + {% endif %}
{% if enable_custom_themes %} diff --git a/code/web/templates/build/_new_deck_tags.html b/code/web/templates/build/_new_deck_tags.html index bda5712..cc5277c 100644 --- a/code/web/templates/build/_new_deck_tags.html +++ b/code/web/templates/build/_new_deck_tags.html @@ -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 %}
{{ pname }}
+ {% 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 %} + + {% endif %} + {% if partner_secondary_name %} +
+ {% if partner_preview_payload.secondary_role_label %}{{ partner_preview_payload.secondary_role_label }}: {% endif %}{{ partner_secondary_name }} +
+ {% endif %} +
+ {{ color_identity(preview_colors, is_colorless=(preview_colors|length == 0), aria_label=preview_label or '', title_text=preview_label or '') }} + {{ preview_label }} +
+ {% if partner_preview_payload.theme_tags %} +
+ Combined themes: {{ partner_preview_payload.theme_tags|join(', ') }} +
+ {% endif %} + {% endif %} diff --git a/code/web/templates/build/_partner_controls.html b/code/web/templates/build/_partner_controls.html new file mode 100644 index 0000000..202bf7d --- /dev/null +++ b/code/web/templates/build/_partner_controls.html @@ -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 %} +
+ Partner Mechanics + {% if not partner_capable %} +

This commander doesn't support partner mechanics or backgrounds.

+ {% else %} + + + +
Choose either a partner commander or a background—never both.
+ {% if partner_role_hint %} +
{{ partner_role_hint }}
+ {% endif %} + {% if primary_partner_with %} +
+ Pairs naturally with {{ primary_partner_with|join(', ') }}. +
+ {% endif %} + {% if partner_options and partner_options|length and (not background_options or not background_options|length) %} +
No Backgrounds available for this commander.
+ {% elif background_options and background_options|length and (not partner_options or not partner_options|length) %} +
This commander can't select a partner commander but can choose a Background.
+ {% endif %} + {% if partner_error %} +
{{ partner_error }}
+ {% endif %} +
+ Partner pairing update: + {% if partner_auto_note %}{{ partner_auto_note }}{% endif %} +
+ {% if partner_prefill_available and partner_auto_default %} +
+ + Toggle to opt-out and choose a different partner. +
+ {% endif %} + {% if partner_suggestions_enabled %} +
+
+ Suggested partners +
+ + +
+
+
+
+
+ {% endif %} +
+ {% if partner_options and partner_options|length %} + + {% endif %} + {% if background_options and background_options|length %} + + {% 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 %} +
+ {% if preview_image %} +
+ {% if preview_href %}{% endif %} + {{ (preview_secondary or 'Selected card') ~ ' card image' }} + {% if preview_href %}{% endif %} +
+ {% endif %} +
+
{{ preview_mode_label }}{% if preview_color_label %} • {{ preview_color_label }}{% endif %}
+ {% if preview_role %} +
{{ preview_role }}
+ {% endif %} + {% if preview_secondary %} +
Pairing: {{ preview_primary }}{% if preview_secondary %} + {{ preview_secondary }}{% endif %}
+ {% endif %} + {% if preview_themes %} +
Theme emphasis: {{ preview_themes|join(', ') }}
+ {% endif %} +
+
+ {% endif %} +
+ + {% endif %} +
+ +{% endif %} diff --git a/code/web/templates/build/_step2.html b/code/web/templates/build/_step2.html index 14b2cd0..6ad2ef7 100644 --- a/code/web/templates/build/_step2.html +++ b/code/web/templates/build/_step2.html @@ -1,5 +1,6 @@
{# Step phases removed #} + {% set partner_preview_payload = partner_preview if partner_preview else (combined_commander if combined_commander else None) %}
+ {% 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 %} + +
+ {{ partner_role_label }}: + {{ partner_secondary_name }} +
+
+ Pairing: {{ partner_primary_name }}{% if partner_secondary_name %} + {{ partner_secondary_name }}{% endif %} +
+ {% if partner_preview_payload.color_label %} +
+ Colors: {{ partner_preview_payload.color_label }} +
+ {% endif %} + {% if partner_theme_tags %} +
+ Theme emphasis: {{ partner_theme_tags|join(', ') }} +
+ {% endif %} + {% endif %}
@@ -40,29 +102,33 @@
Tip: Choose OR for a stronger initial theme pool; switch to AND to tighten synergy.
- {% if recommended and recommended|length %} -
-
Recommended
- +
+
+
Recommended
+ +
+ +
+ {% 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') %} + + {% endfor %} + {% endif %} + +
- -
- {% 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') %} - - {% endfor %} - -
- {% endif %}
{% 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 %}
+ {% set partner_id_prefix = 'step2' %} + {% set partner_scope = 'step2' %} + {% include "build/_partner_controls.html" %} +
Budget/Power Bracket
@@ -108,8 +178,8 @@