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

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

View file

@ -16,7 +16,7 @@ from starlette.middleware.gzip import GZipMiddleware
from typing import Any, Optional, Dict, Iterable, Mapping
from contextlib import asynccontextmanager
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_theme_metrics
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics
from tagging.multi_face_merger import load_merge_summary
from .services.combo_utils import detect_all as _detect_all
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
@ -113,6 +113,8 @@ ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True)
ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True)
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False)
@ -246,6 +248,8 @@ templates.env.globals.update({
"enable_pwa": ENABLE_PWA,
"enable_presets": ENABLE_PRESETS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_partner_mechanics": ENABLE_PARTNER_MECHANICS,
"enable_partner_suggestions": ENABLE_PARTNER_SUGGESTIONS,
"allow_must_haves": ALLOW_MUST_HAVES,
"default_theme": DEFAULT_THEME,
"random_modes": RANDOM_MODES,
@ -834,6 +838,7 @@ async def status_sys():
"ENABLE_CUSTOM_THEMES": bool(ENABLE_CUSTOM_THEMES),
"ENABLE_PWA": bool(ENABLE_PWA),
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
"ENABLE_PARTNER_MECHANICS": bool(ENABLE_PARTNER_MECHANICS),
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
"DEFAULT_THEME": DEFAULT_THEME,
"THEME_MATCH_MODE": DEFAULT_THEME_MATCH_MODE,
@ -910,6 +915,17 @@ async def status_theme_metrics():
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
@app.get("/status/partner_metrics")
async def status_partner_metrics():
if not SHOW_DIAGNOSTICS:
raise HTTPException(status_code=404, detail="Not Found")
try:
return JSONResponse({"ok": True, "metrics": get_partner_metrics()})
except Exception as exc: # pragma: no cover - defensive log
logging.getLogger("web").warning("Failed to fetch partner metrics: %s", exc, exc_info=True)
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
def random_modes_enabled() -> bool:
"""Dynamic check so tests that set env after import still work.
@ -2169,6 +2185,7 @@ from .routes import setup as setup_routes # noqa: E402
from .routes import owned as owned_routes # noqa: E402
from .routes import themes as themes_routes # noqa: E402
from .routes import commanders as commanders_routes # noqa: E402
from .routes import partner_suggestions as partner_suggestions_routes # noqa: E402
app.include_router(build_routes.router)
app.include_router(config_routes.router)
app.include_router(decks_routes.router)
@ -2176,6 +2193,7 @@ app.include_router(setup_routes.router)
app.include_router(owned_routes.router)
app.include_router(themes_routes.router)
app.include_router(commanders_routes.router)
app.include_router(partner_suggestions_routes.router)
# Warm validation cache early to reduce first-call latency in tests and dev
try:

File diff suppressed because it is too large Load diff

View file

@ -153,8 +153,10 @@ def _partner_summary(record: CommanderRecord) -> tuple[str, ...]:
parts: list[str] = []
if record.partner_with:
parts.append("Partner with " + ", ".join(record.partner_with))
elif record.is_partner:
elif getattr(record, "has_plain_partner", False):
parts.append("Partner available")
elif record.is_partner:
parts.append("Partner (restricted)")
if record.supports_backgrounds:
parts.append("Choose a Background")
if record.is_background:

View file

@ -0,0 +1,160 @@
from __future__ import annotations
from typing import Iterable, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from deck_builder.combined_commander import PartnerMode
from ..app import ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS
from ..services.partner_suggestions import get_partner_suggestions
from ..services.telemetry import log_partner_suggestions_generated
router = APIRouter(prefix="/api/partner", tags=["partner suggestions"])
def _parse_modes(values: Optional[Iterable[str]]) -> list[PartnerMode]:
if not values:
return []
modes: list[PartnerMode] = []
seen: set[str] = set()
for value in values:
if not value:
continue
normalized = str(value).strip().replace("-", "_").lower()
if not normalized or normalized in seen:
continue
seen.add(normalized)
for mode in PartnerMode:
if mode.value == normalized:
modes.append(mode)
break
return modes
def _coerce_name_list(values: Optional[Iterable[str]]) -> list[str]:
if not values:
return []
out: list[str] = []
seen: set[str] = set()
for value in values:
if value is None:
continue
text = str(value).strip()
if not text:
continue
key = text.casefold()
if key in seen:
continue
seen.add(key)
out.append(text)
return out
@router.get("/suggestions")
async def partner_suggestions_api(
request: Request,
commander: str = Query(..., min_length=1, description="Primary commander display name"),
limit: int = Query(5, ge=1, le=20, description="Maximum suggestions per partner mode"),
visible_limit: int = Query(3, ge=0, le=10, description="Number of suggestions to mark as visible"),
include_hidden: bool = Query(False, description="When true, include hidden suggestions in the response"),
partner: Optional[List[str]] = Query(None, description="Available partner commander names"),
background: Optional[List[str]] = Query(None, description="Available background names"),
mode: Optional[List[str]] = Query(None, description="Restrict results to specific partner modes"),
refresh: bool = Query(False, description="When true, force a dataset refresh before scoring"),
):
if not (ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS):
raise HTTPException(status_code=404, detail="Partner suggestions are disabled")
commander_name = (commander or "").strip()
if not commander_name:
raise HTTPException(status_code=400, detail="Commander name is required")
include_modes = _parse_modes(mode)
result = get_partner_suggestions(
commander_name,
limit_per_mode=limit,
include_modes=include_modes or None,
refresh_dataset=refresh,
)
if result is None:
raise HTTPException(status_code=503, detail="Partner suggestion dataset is unavailable")
partner_names = _coerce_name_list(partner)
background_names = _coerce_name_list(background)
# If the client didn't provide select options, fall back to the suggestions themselves.
if not partner_names:
for key, entries in result.by_mode.items():
if key == PartnerMode.BACKGROUND.value:
continue
for entry in entries:
if not isinstance(entry, dict):
continue
name_value = entry.get("name")
if isinstance(name_value, str) and name_value.strip():
partner_names.append(name_value)
if not background_names:
background_entries = result.by_mode.get(PartnerMode.BACKGROUND.value, [])
for entry in background_entries:
if not isinstance(entry, dict):
continue
name_value = entry.get("name")
if isinstance(name_value, str) and name_value.strip():
background_names.append(name_value)
partner_names = _coerce_name_list(partner_names)
background_names = _coerce_name_list(background_names)
visible, hidden = result.flatten(partner_names, background_names, visible_limit=visible_limit)
visible_count = len(visible)
hidden_count = len(hidden)
if include_hidden:
combined_visible = visible + hidden
remaining = []
else:
combined_visible = visible
remaining = hidden
payload = {
"commander": {
"display_name": result.display_name,
"canonical": result.canonical,
},
"metadata": result.metadata,
"modes": result.by_mode,
"visible": combined_visible,
"hidden": remaining,
"total": result.total,
"limit": {
"per_mode": limit,
"visible": visible_limit,
},
"available_modes": [mode_key for mode_key, entries in result.by_mode.items() if entries],
"has_hidden": bool(remaining),
}
headers = {"Cache-Control": "no-store"}
try:
mode_counts = {mode_key: len(entries) for mode_key, entries in result.by_mode.items()}
available_modes = [mode_key for mode_key, count in mode_counts.items() if count]
log_partner_suggestions_generated(
request,
commander_display=result.display_name,
commander_canonical=result.canonical,
include_modes=[mode.value for mode in include_modes] if include_modes else [],
available_modes=available_modes,
total=result.total,
mode_counts=mode_counts,
visible_count=visible_count,
hidden_count=hidden_count,
limit_per_mode=limit,
visible_limit=visible_limit,
include_hidden=include_hidden,
refresh_requested=refresh,
dataset_metadata=result.metadata,
)
except Exception: # pragma: no cover - telemetry should not break responses
pass
return JSONResponse(payload, headers=headers)

View file

@ -5,6 +5,7 @@ from fastapi import Request
from ..services import owned_store
from . import orchestrator as orch
from deck_builder import builder_constants as bc
from .. import app as app_module
def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, include_locks: bool = True) -> Dict[str, Any]:
@ -21,6 +22,13 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"partner_enabled": bool(sess.get("partner_enabled") and app_module.ENABLE_PARTNER_MECHANICS),
"secondary_commander": sess.get("secondary_commander"),
"background": sess.get("background"),
"partner_mode": sess.get("partner_mode"),
"partner_warnings": list(sess.get("partner_warnings", []) or []),
"combined_commander": sess.get("combined_commander"),
"partner_auto_note": sess.get("partner_auto_note"),
"owned_set": owned_set(),
"game_changers": bc.GAME_CHANGERS,
"replace_mode": bool(sess.get("replace_mode", True)),
@ -69,6 +77,9 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names_list = owned_names() if (use_owned or prefer) else None
partner_enabled = bool(sess.get("partner_enabled")) and app_module.ENABLE_PARTNER_MECHANICS
secondary_commander = sess.get("secondary_commander") if partner_enabled else None
background_choice = sess.get("background") if partner_enabled else None
ctx = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
@ -87,9 +98,16 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
include_cards=sess.get("include_cards"),
exclude_cards=sess.get("exclude_cards"),
swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
partner_feature_enabled=partner_enabled,
secondary_commander=secondary_commander,
background_commander=background_choice,
)
if set_on_session:
sess["build_ctx"] = ctx
if partner_enabled:
ctx["partner_mode"] = sess.get("partner_mode")
ctx["combined_commander"] = sess.get("combined_commander")
ctx["partner_warnings"] = list(sess.get("partner_warnings", []) or [])
return ctx
@ -109,6 +127,7 @@ def commander_hover_context(
commander_name: str | None,
deck_tags: Iterable[Any] | None,
summary: Dict[str, Any] | None,
combined: Any | None = None,
) -> Dict[str, Any]:
try:
from .summary_utils import format_theme_label, format_theme_list
@ -140,6 +159,13 @@ def commander_hover_context(
result.append(label)
return result
combined_info: Dict[str, Any]
if isinstance(combined, dict):
combined_info = combined
else:
combined_info = {}
has_combined = bool(combined_info)
deck_theme_sources: list[Any] = []
_extend_sources(deck_theme_sources, list(deck_tags or []))
meta_info: Dict[str, Any] = {}
@ -176,6 +202,8 @@ def commander_hover_context(
_extend_sources(commander_theme_sources, commander_meta.get("tags"))
_extend_sources(commander_theme_sources, commander_meta.get("themes"))
_extend_sources(commander_theme_sources, combined_info.get("theme_tags"))
commander_theme_tags = format_theme_list(commander_theme_sources)
if commander_name and not commander_theme_tags:
try:
@ -211,6 +239,36 @@ def commander_hover_context(
slug_seen.add(slug)
commander_tag_slugs.append(slug)
raw_color_identity = combined_info.get("color_identity") if combined_info else None
commander_color_identity: list[str] = []
if isinstance(raw_color_identity, (list, tuple, set)):
for item in raw_color_identity:
token = str(item).strip().upper()
if token:
commander_color_identity.append(token)
commander_color_label = ""
if has_combined:
commander_color_label = str(combined_info.get("color_label") or "").strip()
if not commander_color_label and commander_color_identity:
commander_color_label = " / ".join(commander_color_identity)
if has_combined and not commander_color_label:
commander_color_label = "Colorless (C)"
commander_color_code = str(combined_info.get("color_code") or "").strip() if has_combined else ""
commander_partner_mode = str(combined_info.get("partner_mode") or "").strip() if has_combined else ""
commander_secondary_name = str(combined_info.get("secondary_name") or "").strip() if has_combined else ""
commander_primary_name = str(combined_info.get("primary_name") or commander_name or "").strip()
commander_display_name = commander_primary_name
if commander_secondary_name:
if commander_partner_mode == "background":
commander_display_name = f"{commander_primary_name} + Background: {commander_secondary_name}".strip()
else:
commander_display_name = f"{commander_primary_name} + {commander_secondary_name}".strip()
elif not commander_display_name:
commander_display_name = str(commander_name or "").strip()
reason_bits: list[str] = []
if deck_theme_tags:
reason_bits.append("Deck themes: " + ", ".join(deck_theme_tags))
@ -225,6 +283,13 @@ def commander_hover_context(
"commander_overlap_tags": overlap_tags,
"commander_reason_text": "; ".join(reason_bits),
"commander_role_label": format_theme_label("Commander") if commander_name else "",
"commander_color_identity": commander_color_identity,
"commander_color_label": commander_color_label,
"commander_color_code": commander_color_code,
"commander_partner_mode": commander_partner_mode,
"commander_secondary_name": commander_secondary_name,
"commander_primary_name": commander_primary_name,
"commander_display_name": commander_display_name,
}
@ -274,8 +339,11 @@ def step5_ctx_from_result(
commander_name=ctx.get("commander"),
deck_tags=sess.get("tags"),
summary=ctx.get("summary") if ctx.get("summary") else res.get("summary"),
combined=ctx.get("combined_commander"),
)
ctx.update(hover_meta)
if "commander_display_name" not in ctx or not ctx.get("commander_display_name"):
ctx["commander_display_name"] = ctx.get("commander")
return ctx

View file

@ -24,12 +24,16 @@ import re
from urllib.parse import quote
from path_util import csv_dir
from deck_builder.partner_background_utils import analyze_partner_background
__all__ = [
"CommanderRecord",
"CommanderCatalog",
"load_commander_catalog",
"clear_commander_catalog_cache",
"find_commander_record",
"normalized_restricted_labels",
"shared_restricted_partner_label",
]
@ -80,9 +84,13 @@ class CommanderRecord:
image_small_url: str
image_normal_url: str
partner_with: Tuple[str, ...]
has_plain_partner: bool
is_partner: bool
supports_backgrounds: bool
is_background: bool
is_doctor: bool
is_doctors_companion: bool
restricted_partner_labels: Tuple[str, ...]
search_haystack: str
@ -104,6 +112,36 @@ class CommanderCatalog:
_CACHE: Dict[str, CommanderCatalog] = {}
def normalized_restricted_labels(record: CommanderRecord | object) -> Dict[str, str]:
labels: Dict[str, str] = {}
raw_labels = getattr(record, "restricted_partner_labels", ()) or ()
for label in raw_labels:
text = str(label or "").strip()
if not text:
continue
key = text.casefold()
if key in labels:
continue
labels[key] = text
return labels
def shared_restricted_partner_label(
primary: CommanderRecord | object,
candidate: CommanderRecord | object,
) -> Optional[str]:
primary_labels = normalized_restricted_labels(primary)
if not primary_labels:
return None
candidate_labels = normalized_restricted_labels(candidate)
if not candidate_labels:
return None
for key, display in candidate_labels.items():
if key in primary_labels:
return display
return None
def clear_commander_catalog_cache() -> None:
"""Clear the in-memory commander catalog cache (testing/support)."""
@ -135,6 +173,31 @@ def load_commander_catalog(
return catalog
def find_commander_record(name: str | None) -> CommanderRecord | None:
"""Return the first commander record matching the provided name.
Matching is case-insensitive and considers display name, face name, raw name,
and slug variants. Returns ``None`` when the commander cannot be located.
"""
text = _clean_str(name)
if not text:
return None
lowered = text.casefold()
slug = _slugify(text)
try:
catalog = load_commander_catalog()
except Exception:
return None
for record in catalog.entries:
for candidate in (record.display_name, record.face_name, record.name):
if candidate and candidate.casefold() == lowered:
return record
if record.slug == slug:
return record
return None
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
@ -220,15 +283,17 @@ def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> Comm
theme_tokens = tuple(dict.fromkeys(t.lower() for t in themes if t))
edhrec_rank = _parse_int(row.get("edhrecRank"))
layout = _clean_str(row.get("layout")) or "normal"
partner_with = tuple(_extract_partner_with(oracle_text))
is_partner = bool(
partner_with
or _contains_keyword(oracle_text, "partner")
or _contains_keyword(oracle_text, "friends forever")
or _contains_keyword(oracle_text, "doctor's companion")
)
supports_backgrounds = _contains_keyword(oracle_text, "choose a background")
is_background = "background" in (type_line.lower() if type_line else "")
detection = analyze_partner_background(type_line, oracle_text, raw_themes)
partner_with = detection.partner_with
if not partner_with:
partner_with = tuple(_parse_literal_list(row.get("partnerWith")))
has_plain_partner = detection.has_plain_partner
is_partner = detection.has_partner
supports_backgrounds = detection.choose_background
is_background = detection.is_background
is_doctor = detection.is_doctor
is_doctors_companion = detection.is_doctors_companion
restricted_partner_labels = tuple(detection.restricted_partner_labels)
image_small_url = _build_scryfall_url(display_name, "small")
image_normal_url = _build_scryfall_url(display_name, "normal")
@ -261,9 +326,13 @@ def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> Comm
image_small_url=image_small_url,
image_normal_url=image_normal_url,
partner_with=partner_with,
has_plain_partner=has_plain_partner,
is_partner=is_partner,
supports_backgrounds=supports_backgrounds,
is_background=is_background,
is_doctor=is_doctor,
is_doctors_companion=is_doctors_companion,
restricted_partner_labels=restricted_partner_labels,
search_haystack=search_haystack,
)
@ -277,7 +346,14 @@ def _clean_str(value: object) -> str:
def _clean_multiline(value: object) -> str:
if value is None:
return ""
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
text = str(value)
if "\\r\\n" in text or "\\n" in text or "\\r" in text:
text = (
text.replace("\\r\\n", "\n")
.replace("\\r", "\n")
.replace("\\n", "\n")
)
text = text.replace("\r\n", "\n").replace("\r", "\n")
return "\n".join(line.rstrip() for line in text.split("\n"))
@ -334,35 +410,6 @@ def _split_to_list(value: object) -> List[str]:
parts = [part.strip() for part in text.split(",")]
return [part for part in parts if part]
def _extract_partner_with(text: str) -> List[str]:
if not text:
return []
out: List[str] = []
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
anchor = "Partner with "
if anchor not in line:
continue
after = line.split(anchor, 1)[1]
# Remove reminder text in parentheses and trailing punctuation.
target = after.split("(", 1)[0]
target = target.replace(" and ", ",")
for token in target.split(","):
cleaned = token.strip().strip(".")
if cleaned:
out.append(cleaned)
return out
def _contains_keyword(text: str, needle: str) -> bool:
if not text:
return False
return needle.lower() in text.lower()
def _parse_color_identity(value: object) -> Tuple[Tuple[str, ...], bool]:
text = _clean_str(value)
if not text:

View file

@ -12,6 +12,11 @@ from datetime import datetime as _dt
import re
import unicodedata
from glob import glob
import subprocess
import sys
from pathlib import Path
from deck_builder.partner_selection import apply_partner_inputs
from exceptions import CommanderPartnerError
_TAG_ACRONYM_KEEP = {"EDH", "ETB", "ETBs", "CMC", "ET", "OTK"}
_REASON_SOURCE_OVERRIDES = {
@ -185,6 +190,93 @@ def _run_theme_metadata_enrichment(out_func=None) -> None:
return
def _maybe_refresh_partner_synergy(out_func=None, *, force: bool = False, root: str | os.PathLike[str] | None = None) -> None:
"""Generate partner synergy dataset when missing or stale.
The helper executes the build_partner_suggestions script when the analytics
payload is absent or older than its source assets. Failures are logged but do
not block the calling workflow.
"""
try:
root_path = Path(root) if root is not None else Path(__file__).resolve().parents[3]
except Exception:
return
try:
script_path = root_path / "code" / "scripts" / "build_partner_suggestions.py"
if not script_path.exists():
return
dataset_dir = root_path / "config" / "analytics"
output_path = dataset_dir / "partner_synergy.json"
needs_refresh = force or not output_path.exists()
dataset_mtime = 0.0
if output_path.exists():
try:
dataset_mtime = output_path.stat().st_mtime
except Exception:
dataset_mtime = 0.0
if not needs_refresh:
source_times: list[float] = []
candidates = [
root_path / "config" / "themes" / "theme_list.json",
root_path / "csv_files" / "commander_cards.csv",
]
for candidate in candidates:
try:
if candidate.exists():
source_times.append(candidate.stat().st_mtime)
except Exception:
continue
try:
deck_dir = root_path / "deck_files"
if deck_dir.is_dir():
latest_deck_mtime = 0.0
for pattern in ("*.json", "*.csv", "*.txt"):
for entry in deck_dir.rglob(pattern):
try:
mt = entry.stat().st_mtime
except Exception:
continue
if mt > latest_deck_mtime:
latest_deck_mtime = mt
if latest_deck_mtime:
source_times.append(latest_deck_mtime)
except Exception:
pass
newest_source = max(source_times) if source_times else 0.0
if newest_source and dataset_mtime < newest_source:
needs_refresh = True
if not needs_refresh:
return
try:
dataset_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
cmd = [sys.executable, str(script_path), "--output", str(output_path)]
try:
subprocess.run(cmd, check=True, cwd=str(root_path))
if out_func:
try:
out_func("Partner suggestions dataset refreshed.")
except Exception:
pass
except Exception as exc:
if out_func:
try:
out_func(f"Partner suggestions dataset refresh failed: {exc}")
except Exception:
pass
except Exception:
return
def _global_prune_disallowed_pool(b: DeckBuilder) -> None:
"""Hard-prune disallowed categories from the working pool based on bracket limits.
@ -1054,6 +1146,10 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
_run_theme_metadata_enrichment(out_func)
except Exception:
pass
try:
_maybe_refresh_partner_synergy(out_func, force=force)
except Exception:
pass
# Bust theme-related in-memory caches so new catalog reflects immediately
try:
from .theme_catalog_loader import bust_filter_cache # type: ignore
@ -1722,6 +1818,28 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
"csv": csv_path,
"txt": txt_path,
}
try:
commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined]
except Exception:
commander_meta = {}
names = commander_meta.get("commander_names") or []
if names:
meta["commander_names"] = names
combined_payload = commander_meta.get("combined_commander")
if combined_payload:
meta["combined_commander"] = combined_payload
partner_mode = commander_meta.get("partner_mode")
if partner_mode:
meta["partner_mode"] = partner_mode
color_identity = commander_meta.get("color_identity")
if color_identity:
meta["color_identity"] = color_identity
primary_commander = commander_meta.get("primary_commander")
if primary_commander:
meta["commander"] = primary_commander
secondary_commander = commander_meta.get("secondary_commander")
if secondary_commander:
meta["secondary_commander"] = secondary_commander
# Attach custom deck name if provided
try:
custom_base = getattr(b, 'custom_export_base', None)
@ -1842,6 +1960,111 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
return stages
def _apply_combined_commander_to_builder(builder: DeckBuilder, combined: Any) -> None:
"""Attach combined commander metadata to the builder."""
try:
builder.combined_commander = combined # type: ignore[attr-defined]
except Exception:
pass
try:
builder.partner_mode = getattr(combined, "partner_mode", None) # type: ignore[attr-defined]
except Exception:
pass
try:
builder.secondary_commander = getattr(combined, "secondary_name", None) # type: ignore[attr-defined]
except Exception:
pass
try:
builder.combined_color_identity = getattr(combined, "color_identity", None) # type: ignore[attr-defined]
builder.combined_theme_tags = getattr(combined, "theme_tags", None) # type: ignore[attr-defined]
builder.partner_warnings = getattr(combined, "warnings", None) # type: ignore[attr-defined]
except Exception:
pass
commander_dict = getattr(builder, "commander_dict", None)
if isinstance(commander_dict, dict):
try:
mode = getattr(getattr(combined, "partner_mode", None), "value", None)
commander_dict["Partner Mode"] = mode
commander_dict["Secondary Commander"] = getattr(combined, "secondary_name", None)
except Exception:
pass
def _add_secondary_commander_card(builder: DeckBuilder, commander_df: Any, combined: Any) -> None:
"""Ensure the partnered/background commander is present in the deck library."""
try:
secondary_name = getattr(combined, "secondary_name", None)
except Exception:
secondary_name = None
if not secondary_name:
return
try:
display_name = str(secondary_name).strip()
except Exception:
return
if not display_name:
return
try:
df = commander_df
if df is None:
return
match = df[df["name"].astype(str).str.casefold() == display_name.casefold()]
if match.empty and "faceName" in getattr(df, "columns", []):
match = df[df["faceName"].astype(str).str.casefold() == display_name.casefold()]
if match.empty:
return
row = match.iloc[0]
except Exception:
return
card_name = str(row.get("name") or display_name).strip()
card_type = str(row.get("type") or row.get("type_line") or "")
mana_cost = str(row.get("manaCost") or "")
mana_value = row.get("manaValue", row.get("cmc"))
try:
if mana_value in ("", None):
mana_value = None
else:
mana_value = float(mana_value)
except Exception:
mana_value = None
raw_creatures = row.get("creatureTypes")
if isinstance(raw_creatures, str):
creature_types = [part.strip() for part in raw_creatures.split(",") if part.strip()]
elif isinstance(raw_creatures, (list, tuple)):
creature_types = [str(part).strip() for part in raw_creatures if str(part).strip()]
else:
creature_types = []
raw_tags = row.get("themeTags")
if isinstance(raw_tags, str):
tags = [part.strip() for part in raw_tags.split(",") if part.strip()]
elif isinstance(raw_tags, (list, tuple)):
tags = [str(part).strip() for part in raw_tags if str(part).strip()]
else:
tags = []
try:
builder.add_card(
card_name=card_name,
card_type=card_type,
mana_cost=mana_cost,
mana_value=mana_value,
creature_types=creature_types,
tags=tags,
is_commander=True,
sub_role="Partner",
added_by="Partner Mechanics",
)
except Exception:
return
def start_build_ctx(
commander: str,
tags: List[str],
@ -1861,6 +2084,9 @@ def start_build_ctx(
include_cards: List[str] | None = None,
exclude_cards: List[str] | None = None,
swap_mdfc_basics: bool | None = None,
partner_feature_enabled: bool | None = None,
secondary_commander: str | None = None,
background_commander: str | None = None,
) -> Dict[str, Any]:
logs: List[str] = []
@ -1882,6 +2108,32 @@ def start_build_ctx(
if row.empty:
raise ValueError(f"Commander not found: {commander}")
b._apply_commander_selection(row.iloc[0])
if secondary_commander is not None:
secondary_commander = str(secondary_commander).strip()
if not secondary_commander:
secondary_commander = None
if background_commander is not None:
background_commander = str(background_commander).strip()
if not background_commander:
background_commander = None
combined_partner = None
if partner_feature_enabled and (secondary_commander or background_commander):
try:
combined_partner = apply_partner_inputs(
b,
primary_name=str(commander),
secondary_name=secondary_commander,
background_name=background_commander,
feature_enabled=True,
)
except CommanderPartnerError as exc:
out(f"Partner selection error: {exc}")
except Exception as exc:
out(f"Partner selection failed: {exc}")
else:
if combined_partner is not None:
_apply_combined_commander_to_builder(b, combined_partner)
_add_secondary_commander_card(b, df, combined_partner)
# Tags (explicit + supplemental applied upstream)
b.selected_tags = list(tags or [])
b.primary_tag = b.selected_tags[0] if len(b.selected_tags) > 0 else None
@ -2158,6 +2410,28 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"csv": ctx.get("csv_path"),
"txt": ctx.get("txt_path"),
}
try:
commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined]
except Exception:
commander_meta = {}
names = commander_meta.get("commander_names") or []
if names:
meta["commander_names"] = names
combined_payload = commander_meta.get("combined_commander")
if combined_payload:
meta["combined_commander"] = combined_payload
partner_mode = commander_meta.get("partner_mode")
if partner_mode:
meta["partner_mode"] = partner_mode
color_identity = commander_meta.get("color_identity")
if color_identity:
meta["color_identity"] = color_identity
primary_commander = commander_meta.get("primary_commander")
if primary_commander:
meta["commander"] = primary_commander
secondary_commander = commander_meta.get("secondary_commander")
if secondary_commander:
meta["secondary_commander"] = secondary_commander
try:
custom_base = getattr(b, 'custom_export_base', None)
except Exception:
@ -2961,6 +3235,28 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"csv": ctx.get("csv_path"),
"txt": ctx.get("txt_path"),
}
try:
commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined]
except Exception:
commander_meta = {}
names = commander_meta.get("commander_names") or []
if names:
meta["commander_names"] = names
combined_payload = commander_meta.get("combined_commander")
if combined_payload:
meta["combined_commander"] = combined_payload
partner_mode = commander_meta.get("partner_mode")
if partner_mode:
meta["partner_mode"] = partner_mode
color_identity = commander_meta.get("color_identity")
if color_identity:
meta["color_identity"] = color_identity
primary_commander = commander_meta.get("primary_commander")
if primary_commander:
meta["commander"] = primary_commander
secondary_commander = commander_meta.get("secondary_commander")
if secondary_commander:
meta["secondary_commander"] = secondary_commander
try:
custom_base = getattr(b, 'custom_export_base', None)
except Exception:

View file

@ -0,0 +1,595 @@
"""Partner suggestion dataset loader and scoring utilities."""
from __future__ import annotations
from dataclasses import dataclass
import json
import os
from pathlib import Path
from threading import Lock
from types import SimpleNamespace
from typing import Any, Iterable, Mapping, Optional, Sequence
from code.logging_util import get_logger
from deck_builder.combined_commander import CombinedCommander, PartnerMode, build_combined_commander
from deck_builder.suggestions import (
PartnerSuggestionContext,
ScoreResult,
is_noise_theme,
score_partner_candidate,
)
from deck_builder.color_identity_utils import canon_color_code, color_label_from_code
from deck_builder.partner_selection import normalize_lookup_name
from exceptions import CommanderPartnerError
LOGGER = get_logger(__name__)
_COLOR_NAME_MAP = {
"W": "White",
"U": "Blue",
"B": "Black",
"R": "Red",
"G": "Green",
"C": "Colorless",
}
_MODE_DISPLAY = {
PartnerMode.PARTNER.value: "Partner",
PartnerMode.PARTNER_WITH.value: "Partner With",
PartnerMode.BACKGROUND.value: "Choose a Background",
PartnerMode.DOCTOR_COMPANION.value: "Doctor & Companion",
}
_NOTE_LABELS = {
"partner_with_match": "Canonical Partner With pair",
"background_compatible": "Ideal background match",
"doctor_companion_match": "Doctor ↔ Companion pairing",
"shared_partner_keyword": "Both commanders have Partner",
"restricted_label_match": "Restricted partner label matches",
"observed_pairing": "Popular pairing in exported decks",
}
def _to_tuple(values: Iterable[str] | None) -> tuple[str, ...]:
if not values:
return tuple()
result: list[str] = []
seen: set[str] = set()
for value in values:
token = str(value or "").strip()
if not token:
continue
key = token.casefold()
if key in seen:
continue
seen.add(key)
result.append(token)
return tuple(result)
def _normalize(value: str | None) -> str:
return normalize_lookup_name(value)
def _color_code(identity: Iterable[str]) -> str:
code = canon_color_code(tuple(identity))
return code or "C"
def _color_label(identity: Iterable[str]) -> str:
return color_label_from_code(_color_code(identity))
def _mode_label(mode: PartnerMode | str | None) -> str:
if isinstance(mode, PartnerMode):
return _MODE_DISPLAY.get(mode.value, mode.value)
if isinstance(mode, str):
return _MODE_DISPLAY.get(mode, mode.title())
return "Partner Mechanics"
@dataclass(frozen=True)
class CommanderEntry:
"""Commander metadata extracted from the partner synergy dataset."""
key: str
name: str
display_name: str
payload: Mapping[str, Any]
partner_payload: Mapping[str, Any]
color_identity: tuple[str, ...]
themes: tuple[str, ...]
role_tags: tuple[str, ...]
def to_source(self) -> SimpleNamespace:
partner = self.partner_payload
partner_with = _to_tuple(partner.get("partner_with"))
supports_backgrounds = bool(partner.get("supports_backgrounds") or partner.get("choose_background"))
is_partner = bool(partner.get("has_partner") or partner.get("has_plain_partner"))
is_background = bool(partner.get("is_background"))
is_doctor = bool(partner.get("is_doctor"))
is_companion = bool(partner.get("is_doctors_companion"))
restricted_labels = _to_tuple(partner.get("restricted_partner_labels"))
return SimpleNamespace(
name=self.name,
display_name=self.display_name,
color_identity=self.color_identity,
colors=self.color_identity,
themes=self.themes,
theme_tags=self.themes,
raw_tags=self.themes,
partner_with=partner_with,
supports_backgrounds=supports_backgrounds,
is_partner=is_partner,
is_background=is_background,
is_doctor=is_doctor,
is_doctors_companion=is_companion,
restricted_partner_labels=restricted_labels,
oracle_text="",
type_line="",
)
@property
def canonical(self) -> str:
return self.key
@dataclass(frozen=True)
class PartnerSuggestionResult:
"""Structured partner suggestions grouped by mode."""
commander: str
display_name: str
canonical: str
metadata: Mapping[str, Any]
by_mode: Mapping[str, list[dict[str, Any]]]
total: int
def flatten(
self,
partner_names: Iterable[str],
background_names: Iterable[str],
*,
visible_limit: int = 3,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
partner_allowed = {_normalize(name) for name in partner_names if name}
background_allowed = {_normalize(name) for name in background_names if name}
ordered_modes = [
PartnerMode.PARTNER_WITH.value,
PartnerMode.PARTNER.value,
PartnerMode.DOCTOR_COMPANION.value,
PartnerMode.BACKGROUND.value,
]
visible: list[dict[str, Any]] = []
hidden: list[dict[str, Any]] = []
for mode_key in ordered_modes:
suggestions = self.by_mode.get(mode_key, [])
for suggestion in suggestions:
name_key = _normalize(suggestion.get("name"))
if mode_key == PartnerMode.BACKGROUND.value:
if name_key not in background_allowed:
continue
else:
if name_key not in partner_allowed:
continue
target = visible if len(visible) < visible_limit else hidden
target.append(suggestion)
return visible, hidden
class PartnerSuggestionDataset:
"""Cached partner synergy dataset accessor."""
def __init__(self, path: Path) -> None:
self.path = path
self._payload: Optional[dict[str, Any]] = None
self._metadata: dict[str, Any] = {}
self._entries: dict[str, CommanderEntry] = {}
self._lookup: dict[str, CommanderEntry] = {}
self._pairing_counts: dict[tuple[str, str, str], int] = {}
self._context: PartnerSuggestionContext = PartnerSuggestionContext()
self._mtime_ns: int = -1
@property
def metadata(self) -> Mapping[str, Any]:
return self._metadata
@property
def context(self) -> PartnerSuggestionContext:
return self._context
def ensure_loaded(self, *, force: bool = False) -> None:
if not self.path.exists():
raise FileNotFoundError(self.path)
stat = self.path.stat()
if not force and self._payload is not None and stat.st_mtime_ns == self._mtime_ns:
return
raw = json.loads(self.path.read_text(encoding="utf-8") or "{}")
if not isinstance(raw, dict):
raise ValueError("partner synergy dataset is not a JSON object")
commanders = raw.get("commanders") or {}
if not isinstance(commanders, Mapping):
raise ValueError("commanders section missing in partner synergy dataset")
entries: dict[str, CommanderEntry] = {}
lookup: dict[str, CommanderEntry] = {}
for key, payload in commanders.items():
if not isinstance(payload, Mapping):
continue
display = str(payload.get("display_name") or payload.get("name") or key or "").strip()
if not display:
continue
name = str(payload.get("name") or display)
partner_payload = payload.get("partner") or {}
if not isinstance(partner_payload, Mapping):
partner_payload = {}
color_identity = _to_tuple(payload.get("color_identity"))
themes = tuple(
theme for theme in _to_tuple(payload.get("themes")) if not is_noise_theme(theme)
)
role_tags = _to_tuple(payload.get("role_tags"))
entry = CommanderEntry(
key=str(key),
name=name,
display_name=display,
payload=payload,
partner_payload=partner_payload,
color_identity=color_identity,
themes=themes,
role_tags=role_tags,
)
entries[entry.canonical] = entry
aliases = {
_normalize(entry.canonical),
_normalize(entry.display_name),
_normalize(entry.name),
}
for alias in aliases:
if alias and alias not in lookup:
lookup[alias] = entry
pairings: dict[tuple[str, str, str], int] = {}
pairing_block = raw.get("pairings") or {}
records = pairing_block.get("records") if isinstance(pairing_block, Mapping) else None
if isinstance(records, Sequence):
for record in records:
if not isinstance(record, Mapping):
continue
mode = str(record.get("mode") or "unknown").strip().replace("-", "_")
primary_key = _normalize(record.get("primary_canonical") or record.get("primary"))
secondary_key = _normalize(record.get("secondary_canonical") or record.get("secondary"))
if not mode or not primary_key or not secondary_key:
continue
try:
count = int(record.get("count", 0))
except Exception:
count = 0
if count <= 0:
continue
pairings[(mode, primary_key, secondary_key)] = count
pairings[(mode, secondary_key, primary_key)] = count
self._payload = raw
self._metadata = dict(raw.get("metadata") or {})
self._entries = entries
self._lookup = lookup
self._pairing_counts = pairings
self._context = PartnerSuggestionContext.from_dataset(raw)
self._mtime_ns = stat.st_mtime_ns
def lookup(self, name: str) -> Optional[CommanderEntry]:
key = _normalize(name)
if not key:
return None
entry = self._lookup.get(key)
if entry is not None:
return entry
return self._entries.get(key)
def entries(self) -> Iterable[CommanderEntry]:
return self._entries.values()
def pairing_count(self, mode: PartnerMode, primary: CommanderEntry, secondary: CommanderEntry) -> int:
return int(self._pairing_counts.get((mode.value, primary.canonical, secondary.canonical), 0))
def build_combined(
self,
primary: CommanderEntry,
candidate: CommanderEntry,
mode: PartnerMode,
) -> CombinedCommander:
primary_src = primary.to_source()
candidate_src = candidate.to_source()
return build_combined_commander(primary_src, candidate_src, mode)
ROOT_DIR = Path(__file__).resolve().parents[3]
DEFAULT_DATASET_PATH = (ROOT_DIR / "config" / "analytics" / "partner_synergy.json").resolve()
_DATASET_ENV_VAR = "PARTNER_SUGGESTIONS_DATASET"
_ENV_OVERRIDE = os.getenv(_DATASET_ENV_VAR)
_DATASET_PATH: Path = Path(_ENV_OVERRIDE).expanduser().resolve() if _ENV_OVERRIDE else DEFAULT_DATASET_PATH
_DATASET_CACHE: Optional[PartnerSuggestionDataset] = None
_DATASET_LOCK = Lock()
_DATASET_REFRESH_ATTEMPTED = False
def configure_dataset_path(path: str | Path | None) -> None:
"""Override the dataset path (primarily for tests)."""
global _DATASET_PATH, _DATASET_CACHE
if path is None:
_DATASET_PATH = DEFAULT_DATASET_PATH
os.environ.pop(_DATASET_ENV_VAR, None)
else:
resolved = Path(path).expanduser().resolve()
_DATASET_PATH = resolved
os.environ[_DATASET_ENV_VAR] = str(resolved)
_DATASET_CACHE = None
def load_dataset(*, force: bool = False, refresh: bool = False) -> Optional[PartnerSuggestionDataset]:
"""Return the cached dataset, reloading if needed.
Args:
force: When True, bypass the in-memory cache and reload the dataset from disk.
refresh: When True, attempt to regenerate the dataset before loading. This
resets the "already tried" guard so manual refresh actions can retry
regeneration after an earlier failure.
"""
global _DATASET_CACHE, _DATASET_REFRESH_ATTEMPTED
with _DATASET_LOCK:
if refresh:
_DATASET_REFRESH_ATTEMPTED = False
_DATASET_CACHE = None
dataset = _DATASET_CACHE
if dataset is None or force or refresh:
dataset = PartnerSuggestionDataset(_DATASET_PATH)
try:
dataset.ensure_loaded(force=force or refresh or dataset is not _DATASET_CACHE)
except FileNotFoundError:
LOGGER.debug("partner suggestions dataset missing at %s", _DATASET_PATH)
# Attempt to materialize the dataset automatically when using the default path.
allow_auto_refresh = (
_DATASET_PATH == DEFAULT_DATASET_PATH
and (refresh or not _DATASET_REFRESH_ATTEMPTED)
)
if allow_auto_refresh:
_DATASET_REFRESH_ATTEMPTED = True
try:
from .orchestrator import _maybe_refresh_partner_synergy # type: ignore
_maybe_refresh_partner_synergy(None, force=True)
except Exception as refresh_exc: # pragma: no cover - best-effort
LOGGER.debug(
"partner suggestions auto-refresh failed: %s",
refresh_exc,
exc_info=True,
)
try:
dataset.ensure_loaded(force=True)
except FileNotFoundError:
LOGGER.debug(
"partner suggestions dataset still missing after auto-refresh",
exc_info=True,
)
if refresh:
_DATASET_REFRESH_ATTEMPTED = False
_DATASET_CACHE = None
return None
except Exception as exc: # pragma: no cover - defensive logging
LOGGER.warning("partner suggestions dataset failed after auto-refresh", exc_info=exc)
if refresh:
_DATASET_REFRESH_ATTEMPTED = False
_DATASET_CACHE = None
return None
else:
_DATASET_CACHE = None
return None
except Exception as exc: # pragma: no cover - defensive logging
LOGGER.warning("partner suggestions dataset failed to load", exc_info=exc)
_DATASET_CACHE = None
return None
_DATASET_CACHE = dataset
return dataset
def _shared_restriction_label(primary: CommanderEntry, candidate: CommanderEntry) -> Optional[str]:
primary_labels = set(_to_tuple(primary.partner_payload.get("restricted_partner_labels")))
candidate_labels = set(_to_tuple(candidate.partner_payload.get("restricted_partner_labels")))
shared = primary_labels & candidate_labels
if not shared:
return None
return sorted(shared, key=str.casefold)[0]
def _color_delta(primary: CommanderEntry, combined: CombinedCommander) -> dict[str, list[str]]:
primary_colors = {color.upper() for color in primary.color_identity}
combined_colors = {color.upper() for color in combined.color_identity or ()}
added = [
_COLOR_NAME_MAP.get(color, color)
for color in sorted(combined_colors - primary_colors)
]
removed = [
_COLOR_NAME_MAP.get(color, color)
for color in sorted(primary_colors - combined_colors)
]
return {
"added": added,
"removed": removed,
}
def _reason_summary(
result: ScoreResult,
shared_themes: Sequence[str],
pairing_count: int,
color_delta: Mapping[str, Sequence[str]],
) -> tuple[str, list[str]]:
parts: list[str] = []
details: list[str] = []
score_percent = int(round(max(0.0, min(1.0, result.score)) * 100))
parts.append(f"{score_percent}% match")
if shared_themes:
label = ", ".join(shared_themes[:2])
parts.append(f"Shared themes: {label}")
if pairing_count > 0:
parts.append(f"Seen in {pairing_count} decks")
for note in result.notes:
label = _NOTE_LABELS.get(note)
if label and label not in details:
details.append(label)
if not details and pairing_count > 0:
details.append(f"Observed together {pairing_count} time(s)")
added = color_delta.get("added") or []
if added:
details.append("Adds " + ", ".join(added))
overlap_component = float(result.components.get("overlap", 0.0))
if overlap_component >= 0.35 and len(parts) < 3:
percent = int(round(overlap_component * 100))
details.append(f"Theme overlap {percent}%")
summary = "".join(parts[:3])
return summary, details
def _build_suggestion_payload(
primary: CommanderEntry,
candidate: CommanderEntry,
mode: PartnerMode,
result: ScoreResult,
combined: CombinedCommander,
pairing_count: int,
) -> dict[str, Any]:
shared_themes = sorted(
{
theme
for theme in primary.themes
if theme in candidate.themes and not is_noise_theme(theme)
},
key=str.casefold,
)
color_delta = _color_delta(primary, combined)
summary, details = _reason_summary(result, shared_themes, pairing_count, color_delta)
suggestion = {
"name": candidate.display_name,
"mode": mode.value,
"mode_label": _mode_label(mode),
"score": max(0.0, min(1.0, float(result.score))),
"score_percent": int(round(max(0.0, min(1.0, float(result.score))) * 100)),
"score_components": dict(result.components),
"notes": list(result.notes),
"shared_themes": shared_themes,
"candidate_themes": list(candidate.themes),
"theme_tags": list(combined.theme_tags or ()),
"summary": summary,
"reasons": details,
"pairing_count": pairing_count,
"color_code": combined.color_code or _color_code(combined.color_identity or ()),
"color_label": combined.color_label or _color_label(combined.color_identity or ()),
"color_identity": list(combined.color_identity or ()),
"candidate_colors": list(candidate.color_identity),
"primary_colors": list(combined.primary_color_identity or primary.color_identity),
"secondary_colors": list(combined.secondary_color_identity or candidate.color_identity),
"color_delta": color_delta,
"restriction_label": _shared_restriction_label(primary, candidate),
}
if combined.secondary_name:
suggestion["secondary_name"] = combined.secondary_name
suggestion["preview"] = {
"primary_name": combined.primary_name,
"secondary_name": combined.secondary_name,
"partner_mode": mode.value,
"partner_mode_label": _mode_label(mode),
"color_label": suggestion["color_label"],
"color_code": suggestion["color_code"],
"theme_tags": list(combined.theme_tags or ()),
"secondary_role_label": getattr(combined, "secondary_name", None) and (
"Background" if mode is PartnerMode.BACKGROUND else (
"Doctor's Companion" if mode is PartnerMode.DOCTOR_COMPANION else "Partner commander"
)
),
}
return suggestion
def get_partner_suggestions(
commander_name: str,
*,
limit_per_mode: int = 5,
include_modes: Optional[Sequence[PartnerMode]] = None,
min_score: float = 0.15,
refresh_dataset: bool = False,
) -> Optional[PartnerSuggestionResult]:
dataset = load_dataset(force=refresh_dataset, refresh=refresh_dataset)
if dataset is None:
return None
primary_entry = dataset.lookup(commander_name)
if primary_entry is None:
return PartnerSuggestionResult(
commander=commander_name,
display_name=commander_name,
canonical=_normalize(commander_name) or commander_name,
metadata=dataset.metadata,
by_mode={},
total=0,
)
allowed_modes = set(include_modes) if include_modes else {
PartnerMode.PARTNER,
PartnerMode.PARTNER_WITH,
PartnerMode.BACKGROUND,
PartnerMode.DOCTOR_COMPANION,
}
grouped: dict[str, list[dict[str, Any]]] = {
PartnerMode.PARTNER.value: [],
PartnerMode.PARTNER_WITH.value: [],
PartnerMode.BACKGROUND.value: [],
PartnerMode.DOCTOR_COMPANION.value: [],
}
total = 0
primary_source = primary_entry.payload
context = dataset.context
for candidate_entry in dataset.entries():
if candidate_entry.canonical == primary_entry.canonical:
continue
try:
result = score_partner_candidate(primary_source, candidate_entry.payload, context=context)
except Exception: # pragma: no cover - defensive scoring guard
continue
mode = result.mode
if mode is PartnerMode.NONE or mode not in allowed_modes:
continue
if result.score < min_score:
continue
try:
combined = dataset.build_combined(primary_entry, candidate_entry, mode)
except CommanderPartnerError:
continue
except Exception: # pragma: no cover - defensive
continue
pairing_count = dataset.pairing_count(mode, primary_entry, candidate_entry)
suggestion = _build_suggestion_payload(primary_entry, candidate_entry, mode, result, combined, pairing_count)
grouped[mode.value].append(suggestion)
total += 1
for mode_key, suggestions in grouped.items():
suggestions.sort(key=lambda item: (-float(item.get("score", 0.0)), item.get("name", "").casefold()))
if limit_per_mode > 0:
grouped[mode_key] = suggestions[:limit_per_mode]
return PartnerSuggestionResult(
commander=primary_entry.display_name,
display_name=primary_entry.display_name,
canonical=primary_entry.canonical,
metadata=dataset.metadata,
by_mode=grouped,
total=sum(len(s) for s in grouped.values()),
)

View file

@ -2,16 +2,19 @@ from __future__ import annotations
import json
import logging
from typing import Any, Dict
from typing import Any, Dict, Mapping, Optional, Sequence
from fastapi import Request
__all__ = [
"log_commander_page_view",
"log_commander_create_deck",
"log_partner_suggestions_generated",
"log_partner_suggestion_selected",
]
_LOGGER = logging.getLogger("web.commander_browser")
_PARTNER_LOGGER = logging.getLogger("web.partner_suggestions")
def _emit(logger: logging.Logger, payload: Dict[str, Any]) -> None:
@ -104,3 +107,113 @@ def log_commander_create_deck(
"client_ip": _client_ip(request),
}
_emit(_LOGGER, payload)
def _extract_dataset_metadata(metadata: Mapping[str, Any] | None) -> Dict[str, Any]:
if not isinstance(metadata, Mapping):
return {}
snapshot: Dict[str, Any] = {}
for key in ("dataset_version", "generated_at", "record_count", "entry_count", "build_id"):
if key in metadata:
snapshot[key] = metadata[key]
if not snapshot:
# Fall back to a small subset to avoid logging the full metadata document.
for key, value in list(metadata.items())[:5]:
snapshot[key] = value
return snapshot
def log_partner_suggestions_generated(
request: Request,
*,
commander_display: str,
commander_canonical: str,
include_modes: Sequence[str] | None,
available_modes: Sequence[str],
total: int,
mode_counts: Mapping[str, int],
visible_count: int,
hidden_count: int,
limit_per_mode: int,
visible_limit: int,
include_hidden: bool,
refresh_requested: bool,
dataset_metadata: Mapping[str, Any] | None = None,
) -> None:
payload: Dict[str, Any] = {
"event": "partner_suggestions.generated",
"request_id": _request_id(request),
"path": str(request.url.path),
"query": _query_snapshot(request),
"commander": {
"display": commander_display,
"canonical": commander_canonical,
},
"limits": {
"per_mode": int(limit_per_mode),
"visible": int(visible_limit),
"include_hidden": bool(include_hidden),
},
"result": {
"total": int(total),
"visible_count": int(visible_count),
"hidden_count": int(hidden_count),
"available_modes": list(available_modes),
"mode_counts": {str(key): int(value) for key, value in mode_counts.items()},
"metadata": _extract_dataset_metadata(dataset_metadata),
},
"filters": {
"include_modes": [str(mode) for mode in (include_modes or [])],
"refresh": bool(refresh_requested),
},
"client_ip": _client_ip(request),
}
_emit(_PARTNER_LOGGER, payload)
def log_partner_suggestion_selected(
request: Request,
*,
commander: str,
scope: str | None,
partner_enabled: bool,
auto_opt_out: bool,
auto_assigned: bool,
selection_source: Optional[str],
secondary_candidate: str | None,
background_candidate: str | None,
resolved_secondary: str | None,
resolved_background: str | None,
partner_mode: str | None,
has_preview: bool,
warnings: Sequence[str] | None,
error: str | None,
) -> None:
payload: Dict[str, Any] = {
"event": "partner_suggestions.selected",
"request_id": _request_id(request),
"path": str(request.url.path),
"scope": scope or "",
"commander": commander,
"partner_enabled": bool(partner_enabled),
"auto_opt_out": bool(auto_opt_out),
"auto_assigned": bool(auto_assigned),
"selection_source": (selection_source or "") or None,
"inputs": {
"secondary_candidate": secondary_candidate,
"background_candidate": background_candidate,
},
"resolved": {
"partner_mode": partner_mode,
"secondary": resolved_secondary,
"background": resolved_background,
},
"preview_available": bool(has_preview),
"warnings_count": len(warnings or []),
"has_error": bool(error),
"error": error,
"client_ip": _client_ip(request),
}
if warnings:
payload["warnings"] = list(warnings)
_emit(_PARTNER_LOGGER, payload)

View file

@ -212,6 +212,18 @@ label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75r
select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
small, .muted{ color: var(--muted); }
.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; }
.partner-preview[hidden]{ display:none !important; }
.partner-preview__header{ font-weight:600; }
.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; }
.partner-preview__art{ flex:0 0 auto; }
.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); }
.partner-preview__details{ flex:1 1 180px; min-width:0; }
.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; }
.partner-preview__pairing{ margin-top:.35rem; }
.partner-preview__themes{ margin-top:.35rem; font-size:12px; }
.partner-preview--static{ margin-bottom:.5rem; }
.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); }
/* Toasts */
.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }

View file

@ -662,7 +662,7 @@
window.__dfcFlipCard = function(card){ if(!card) return; flip(card, card.querySelector('.dfc-toggle')); };
window.__dfcGetFace = function(card){ if(!card) return 'front'; return card.getAttribute(FACE_ATTR) || 'front'; };
function scan(){
document.querySelectorAll('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
document.querySelectorAll('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
}
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; }, { passive:true });
document.addEventListener('DOMContentLoaded', scan);
@ -1206,9 +1206,9 @@
if(!el) return null;
// If inside flip button
var btn = el.closest && el.closest('.dfc-toggle');
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card');
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card');
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
if(container) return container;
// Image-based detection (any card image carrying data-card-name)
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){
@ -1264,7 +1264,7 @@
window.hoverShowByName = function(name){
try {
var el = document.querySelector('[data-card-name="'+CSS.escape(name)+'"]');
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
} catch(_) {}
};
// Keyboard accessibility & focus traversal (P2 UI Hover keyboard accessibility)

View file

@ -32,12 +32,16 @@
</fieldset>
<fieldset>
<legend>Themes</legend>
<div id="newdeck-tags-slot" class="muted">
<em>Select a commander to see theme recommendations and choices.</em>
<input type="hidden" name="primary_tag" />
<input type="hidden" name="secondary_tag" />
<input type="hidden" name="tertiary_tag" />
<input type="hidden" name="tag_mode" value="AND" />
<div id="newdeck-tags-slot"{% if not tag_slot_html %} class="muted"{% endif %}>
{% if tag_slot_html %}
{{ tag_slot_html | safe }}
{% else %}
<em>Select a commander to see theme recommendations and choices.</em>
<input type="hidden" name="primary_tag" />
<input type="hidden" name="secondary_tag" />
<input type="hidden" name="tertiary_tag" />
<input type="hidden" name="tag_mode" value="AND" />
{% endif %}
</div>
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
{% if enable_custom_themes %}

View file

@ -1,4 +1,17 @@
{% from 'partials/_macros.html' import color_identity %}
{% set pname = commander.name %}
{% set partner_preview_payload = partner_preview if partner_preview else None %}
{% set preview_colors = partner_preview_payload.color_identity if partner_preview_payload else [] %}
{% if preview_colors is none %}
{% set preview_colors = [] %}
{% endif %}
{% set preview_label = partner_preview_payload.color_label if partner_preview_payload else '' %}
{% if not preview_label and preview_colors %}
{% set preview_label = preview_colors|join(' / ') %}
{% endif %}
{% if not preview_label and partner_preview_payload and (preview_colors|length == 0) %}
{% set preview_label = 'Colorless (C)' %}
{% endif %}
<div id="newdeck-commander-slot" hx-swap-oob="true" style="max-width:230px;">
<aside class="card-preview" data-card-name="{{ pname }}" style="max-width: 230px;">
<a href="https://scryfall.com/search?q={{ pname|urlencode }}" target="_blank" rel="noopener">
@ -6,6 +19,38 @@
</a>
</aside>
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px;">{{ pname }}</div>
{% if partner_preview_payload %}
{% set partner_secondary_name = partner_preview_payload.secondary_name %}
{% set partner_image_url = partner_preview_payload.secondary_image_url or partner_preview_payload.image_url %}
{% if not partner_image_url and partner_secondary_name %}
{% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_secondary_name|urlencode ~ '&format=image&version=normal' %}
{% endif %}
{% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %}
{% if not partner_href and partner_secondary_name %}
{% set partner_href = 'https://scryfall.com/search?q=' ~ partner_secondary_name|urlencode %}
{% endif %}
{% if partner_image_url %}
<aside class="card-preview partner-card-preview" style="max-width: 230px; margin-top:.75rem;">
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
<img src="{{ partner_image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" data-card-name="{{ partner_secondary_name or '' }}" style="width:200px; height:auto; display:block; border-radius:6px;" loading="lazy" decoding="async" />
{% if partner_href %}</a>{% endif %}
</aside>
{% endif %}
{% if partner_secondary_name %}
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px;">
{% if partner_preview_payload.secondary_role_label %}<strong>{{ partner_preview_payload.secondary_role_label }}</strong>: {% endif %}{{ partner_secondary_name }}
</div>
{% endif %}
<div class="muted" style="font-size:12px; margin-top:.35rem; display:flex; align-items:center; gap:.35rem; flex-wrap:wrap;">
{{ color_identity(preview_colors, is_colorless=(preview_colors|length == 0), aria_label=preview_label or '', title_text=preview_label or '') }}
<span>{{ preview_label }}</span>
</div>
{% if partner_preview_payload.theme_tags %}
<div class="muted" style="font-size:12px; margin-top:.25rem;">
Combined themes: {{ partner_preview_payload.theme_tags|join(', ') }}
</div>
{% endif %}
{% endif %}
<script>
try {
var nm = document.querySelector('input[name="name"]');
@ -52,18 +97,18 @@
<button type="button" id="modal-reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
<span id="modal-tag-count" class="muted" style="font-size:12px;"></span>
</div>
{% if recommended and recommended|length %}
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
<div class="muted" style="font-size:12px;">Recommended</div>
<div id="modal-tag-reco-block" data-has-reco="{% if recommended and recommended|length %}1{% else %}0{% endif %}" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Recommended</div>
<div id="modal-tag-reco" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% if recommended and recommended|length %}
{% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
<button type="button" class="chip chip-reco" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button>
{% endfor %}
{% endif %}
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button>
</div>
</div>
<div id="modal-tag-reco" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
<button type="button" class="chip chip-reco" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button>
{% endfor %}
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
</div>
{% endif %}
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in tags %}
<button type="button" class="chip" data-tag="{{ t }}">{{ t }}</button>
@ -83,6 +128,10 @@
</div>
</div>
{% set partner_id_prefix = 'modal' %}
{% set partner_scope = 'modal' %}
{% include "build/_partner_controls.html" %}
{# Always update the bracket dropdown on commander change; hide 12 only when gc_commander is true #}
<div id="newdeck-bracket-slot" hx-swap-oob="true">
<label>Bracket
@ -102,6 +151,7 @@
<script>
(function(){
var list = document.getElementById('modal-tag-list');
var recoBlock = document.getElementById('modal-tag-reco-block');
var reco = document.getElementById('modal-tag-reco');
var selAll = document.getElementById('modal-reco-select-all');
var resetBtn = document.getElementById('modal-reset-tags');
@ -112,6 +162,18 @@
var countEl = document.getElementById('modal-tag-count');
var selSummary = document.getElementById('modal-selected-themes');
if (!list) return;
var previewScope = 'modal';
function readPartnerPreviewTags(){
if (typeof window === 'undefined') return [];
var store = window.partnerPreviewState;
if (!store) return [];
var state = store[previewScope];
if (!state) return [];
if (Array.isArray(state.theme_tags) && state.theme_tags.length){ return state.theme_tags.slice(); }
var payload = state.payload;
if (payload && Array.isArray(payload.theme_tags)){ return payload.theme_tags.slice(); }
return [];
}
function getSel(){ var a=[]; if(p&&p.value)a.push(p.value); if(s&&s.value)a.push(s.value); if(t&&t.value)a.push(t.value); return a; }
function setSel(a){ a = Array.from(new Set(a||[])).filter(Boolean).slice(0,3); if(p) p.value=a[0]||''; if(s) s.value=a[1]||''; if(t) t.value=a[2]||''; updateUI(); }
@ -135,10 +197,78 @@
try{ document.dispatchEvent(new CustomEvent('newdeck:tagsChanged')); }catch(_){ }
}
if (resetBtn) resetBtn.addEventListener('click', function(){ setSel([]); });
list.querySelectorAll('button.chip').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); });
if (reco){ reco.querySelectorAll('button.chip-reco').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); }); }
if (selAll){ selAll.addEventListener('click', function(){ try{ var cur=getSel(); var recs = reco? Array.from(reco.querySelectorAll('button.chip-reco')).map(function(b){return b.dataset.tag||'';}).filter(Boolean):[]; var combined=cur.slice(); recs.forEach(function(x){ if(combined.indexOf(x)===-1) combined.push(x); }); setSel(combined.slice(-3)); }catch(_){} }); }
list.addEventListener('click', function(e){
var btn = e.target.closest('button.chip');
if (!btn || !list.contains(btn)) return;
var tag = btn.dataset.tag || '';
if (tag){ toggle(tag); }
});
if (reco){
reco.addEventListener('click', function(e){
var btn = e.target.closest('button');
if (!btn || !reco.contains(btn)) return;
if (btn.id === 'modal-reco-select-all'){
try {
var cur = getSel();
var recs = Array.from(reco.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean);
var combined = cur.slice();
recs.forEach(function(x){ if (combined.indexOf(x) === -1) combined.push(x); });
setSel(combined.slice(-3));
} catch(_){ }
return;
}
if (btn.classList.contains('chip-reco')){
var tag = btn.dataset.tag || '';
if (tag){ toggle(tag); }
}
});
}
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
function updatePartnerRecommendations(tags){
if (!reco) return;
Array.from(reco.querySelectorAll('button.partner-suggestion')).forEach(function(btn){ btn.remove(); });
var unique = [];
var seen = new Set();
(Array.isArray(tags) ? tags : []).forEach(function(tag){
var value = String(tag || '').trim();
if (!value) return;
var key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
unique.push(value);
});
var insertBefore = selAll && selAll.parentElement === reco ? selAll : null;
unique.forEach(function(tag){
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip chip-reco partner-suggestion';
btn.dataset.tag = tag;
btn.title = 'Synergizes with selected partner pairing';
btn.textContent = '★ ' + tag;
if (insertBefore){ reco.insertBefore(btn, insertBefore); }
else { reco.appendChild(btn); }
});
var hasAny = reco.querySelectorAll('button.chip-reco').length > 0;
if (recoBlock){
recoBlock.style.display = hasAny ? '' : 'none';
recoBlock.setAttribute('data-has-reco', hasAny ? '1' : '0');
}
if (selAll){ selAll.style.display = hasAny ? '' : 'none'; }
updateUI();
}
document.addEventListener('partner:preview', function(evt){
var detail = (evt && evt.detail) || {};
if (detail.scope && detail.scope !== previewScope) return;
var tags = Array.isArray(detail.theme_tags) && detail.theme_tags.length ? detail.theme_tags : [];
if (!tags.length && detail.payload && Array.isArray(detail.payload.theme_tags)){
tags = detail.payload.theme_tags;
}
updatePartnerRecommendations(tags);
});
var initialPartnerTags = readPartnerPreviewTags();
updatePartnerRecommendations(initialPartnerTags);
updateUI();
})();
</script>

View file

@ -0,0 +1,959 @@
{% set prefix = partner_id_prefix if partner_id_prefix is defined else 'partner' %}
{% set feature_available = partner_feature_available if partner_feature_available is defined else False %}
{% set partner_capable = partner_capable if partner_capable is defined else False %}
{% set partner_options = partner_options if partner_options is defined else [] %}
{% set background_options = background_options if background_options is defined else [] %}
{% set partner_select_label = partner_select_label if partner_select_label is defined else 'Partner commander' %}
{% set partner_select_placeholder = partner_select_placeholder if partner_select_placeholder is defined else 'Select a partner' %}
{% set partner_auto_assigned = partner_auto_assigned if partner_auto_assigned is defined else False %}
{% set partner_auto_opt_out = partner_auto_opt_out if partner_auto_opt_out is defined else False %}
{% set partner_auto_default = partner_auto_default if partner_auto_default is defined else None %}
{% set partner_prefill_available = partner_prefill_available if partner_prefill_available is defined else False %}
{% set partner_note_id = prefix ~ '-partner-autonote' %}
{% set partner_warning_id = prefix ~ '-partner-warnings' %}
{% set partner_suggestions_enabled = partner_suggestions_enabled if partner_suggestions_enabled is defined else False %}
{% set partner_suggestions = partner_suggestions if partner_suggestions is defined else [] %}
{% set partner_suggestions_hidden = partner_suggestions_hidden if partner_suggestions_hidden is defined else [] %}
{% set partner_suggestions_total = partner_suggestions_total if partner_suggestions_total is defined else 0 %}
{% set partner_suggestions_metadata = partner_suggestions_metadata if partner_suggestions_metadata is defined else {} %}
{% set partner_suggestions_loaded = partner_suggestions_loaded if partner_suggestions_loaded is defined else False %}
{% set partner_suggestions_error = partner_suggestions_error if partner_suggestions_error is defined else None %}
{% set partner_suggestions_available = partner_suggestions_available if partner_suggestions_available is defined else False %}
{% set partner_suggestions_has_hidden = partner_suggestions_has_hidden if partner_suggestions_has_hidden is defined else False %}
{% if feature_available %}
<fieldset>
<legend>Partner Mechanics</legend>
{% if not partner_capable %}
<p class="muted" style="font-size:12px;">This commander doesn't support partner mechanics or backgrounds.</p>
{% else %}
<input type="hidden" name="partner_enabled" value="{{ partner_hidden_value or '1' }}" />
<input type="hidden" name="partner_auto_opt_out" value="{{ '1' if partner_auto_opt_out else '0' }}" data-partner-auto-opt="{{ prefix }}" />
<input type="hidden" name="partner_selection_source" value="" data-partner-selection-source="{{ prefix }}" />
<div class="muted" style="font-size:12px; margin-bottom:.5rem;">Choose either a partner commander or a background—never both.</div>
{% if partner_role_hint %}
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">{{ partner_role_hint }}</div>
{% endif %}
{% if primary_partner_with %}
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">
Pairs naturally with <strong>{{ primary_partner_with|join(', ') }}</strong>.
</div>
{% endif %}
{% if partner_options and partner_options|length and (not background_options or not background_options|length) %}
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">No Backgrounds available for this commander.</div>
{% elif background_options and background_options|length and (not partner_options or not partner_options|length) %}
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">This commander can't select a partner commander but can choose a Background.</div>
{% endif %}
{% if partner_error %}
<div style="color:#a00; margin-bottom:.5rem; font-weight:600;">{{ partner_error }}</div>
{% endif %}
<div id="{{ partner_note_id }}" class="partner-autonote" data-partner-autonote="{{ prefix }}" data-autonote="{{ partner_auto_note or '' }}" style="color:#046d1f; margin-bottom:.5rem; font-size:12px;" role="status" aria-live="polite" aria-atomic="true" aria-hidden="{{ 'false' if partner_auto_note else 'true' }}" {% if not partner_auto_note %}hidden{% endif %}>
<span class="sr-only">Partner pairing update:</span>
<span data-partner-note-copy>{% if partner_auto_note %}{{ partner_auto_note }}{% endif %}</span>
</div>
{% if partner_prefill_available and partner_auto_default %}
<div style="display:flex; align-items:center; gap:.5rem; margin-bottom:.5rem; flex-wrap:wrap;">
<button type="button" class="chip{% if not partner_auto_opt_out %} active{% endif %}" data-partner-autotoggle="{{ prefix }}" data-partner-default="{{ partner_auto_default }}" aria-pressed="{% if not partner_auto_opt_out %}true{% else %}false{% endif %}" aria-describedby="{{ partner_note_id }}">
{% if partner_auto_opt_out %}Enable default partner{% else %}Use default partner ({{ partner_auto_default }}){% endif %}
</button>
<span class="muted" style="font-size:12px;">Toggle to opt-out and choose a different partner.</span>
</div>
{% endif %}
{% if partner_suggestions_enabled %}
<div class="partner-suggestions" data-partner-suggestions="{{ prefix }}" data-partner-scope="{{ partner_scope if partner_scope is defined else prefix }}" data-api-endpoint="{{ partner_suggestions_endpoint if partner_suggestions_endpoint is defined else '/api/partner/suggestions' }}" data-primary-name="{{ primary_commander_display }}" data-suggestions-json='{{ partner_suggestions | tojson }}' data-hidden-json='{{ partner_suggestions_hidden | tojson }}' data-total="{{ partner_suggestions_total }}" data-loaded="{{ '1' if partner_suggestions_loaded else '0' }}" data-error="{{ partner_suggestions_error or '' }}" data-has-hidden="{{ '1' if partner_suggestions_has_hidden else '0' }}" data-available="{{ '1' if partner_suggestions_available else '0' }}" data-metadata-json='{{ partner_suggestions_metadata | tojson }}' style="display:grid; gap:.35rem; margin-bottom:.75rem;">
<div class="partner-suggestions__header" style="display:flex; justify-content:space-between; align-items:center; gap:.5rem; flex-wrap:wrap;">
<span style="font-weight:600;">Suggested partners</span>
<div class="partner-suggestions__controls" style="display:flex; gap:.35rem; flex-wrap:wrap;">
<button type="button" class="chip" data-partner-suggestions-refresh="{{ prefix }}" aria-label="Refresh partner suggestions">Refresh</button>
<button type="button" class="chip" data-partner-suggestions-more="{{ prefix }}" hidden>Show more</button>
</div>
</div>
<div class="partner-suggestions__list" data-partner-suggestions-list style="display:flex; flex-wrap:wrap; gap:.35rem;"></div>
<div class="partner-suggestions__meta muted" data-partner-suggestions-meta style="font-size:12px;"></div>
</div>
{% endif %}
<div class="partner-controls" data-partner-controls="{{ prefix }}" data-partner-scope="{{ partner_scope if partner_scope is defined else prefix }}" data-primary-name="{{ primary_commander_display }}" style="display:grid; gap:.5rem; margin-bottom:.5rem;">
{% if partner_options and partner_options|length %}
<label style="display:grid; gap:.35rem;">
<span>{{ partner_select_label }}</span>
<select name="secondary_commander" id="{{ prefix }}-partner-secondary" data-partner-select="secondary" aria-describedby="{{ partner_note_id }} {{ partner_warning_id }}">
<option value="">{{ partner_select_placeholder }}</option>
{% for opt in partner_options %}
{% set is_selected = (selected_secondary_commander|lower == opt.name|lower) %}
<option value="{{ opt.name }}" data-pairing-mode="{{ opt.pairing_mode }}" data-mode-label="{{ opt.mode_label }}" data-color-label="{{ opt.color_label }}" data-color-code="{{ opt.color_code }}" data-image-url="{{ opt.image_url or '' }}" data-scryfall-url="{{ opt.scryfall_url or '' }}" data-role-label="{{ opt.role_label or 'Partner commander' }}" {% if is_selected %}selected{% endif %}>
{{ opt.name }} — {{ opt.color_label }}
{% if opt.pairing_mode == 'partner_with' %}(Partner With){% elif opt.pairing_mode == 'partner_restricted' and opt.restriction_label %} (Partner - {{ opt.restriction_label }}){% elif opt.pairing_mode == 'doctor_companion' and opt.role_label %} ({{ opt.role_label }}){% endif %}
</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if background_options and background_options|length %}
<label style="display:grid; gap:.35rem;">
<span>Background</span>
<select name="background" id="{{ prefix }}-partner-background" data-partner-select="background" aria-describedby="{{ partner_note_id }} {{ partner_warning_id }}">
<option value="">Select a background</option>
{% for opt in background_options %}
<option value="{{ opt.name }}" data-color-label="{{ opt.color_label }}" data-color-code="{{ opt.color_code if opt.color_code is defined else '' }}" data-image-url="{{ opt.image_url or '' }}" data-scryfall-url="{{ opt.scryfall_url or '' }}" data-role-label="{{ opt.role_label or 'Background' }}" {% if selected_background == opt.name %}selected{% endif %}>{{ opt.name }} — {{ opt.color_label }}</option>
{% endfor %}
</select>
</label>
{% endif %}
</div>
<div style="display:flex; gap:.5rem; margin-bottom:.5rem; flex-wrap:wrap;">
<button type="button" class="chip" data-partner-clear="{{ prefix }}">Clear selection</button>
</div>
<div class="partner-preview" data-partner-preview="{{ prefix }}" {% if partner_preview %}data-preview-json='{{ partner_preview | tojson }}'{% else %}hidden{% endif %}>
{% if partner_preview %}
{% set preview_image = partner_preview.secondary_image_url or partner_preview.image_url %}
{% if not preview_image and partner_preview.secondary_name %}
{% set preview_image = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_preview.secondary_name|urlencode ~ '&format=image&version=normal' %}
{% endif %}
{% set preview_href = partner_preview.secondary_scryfall_url or partner_preview.scryfall_url %}
{% if not preview_href and partner_preview.secondary_name %}
{% set preview_href = 'https://scryfall.com/search?q=' ~ partner_preview.secondary_name|urlencode %}
{% endif %}
{% set preview_role = partner_preview.secondary_role_label or partner_preview.role_label %}
{% set preview_primary = partner_preview.primary_name or primary_commander_display %}
{% set preview_secondary = partner_preview.secondary_name %}
{% set preview_themes = partner_preview.theme_tags %}
{% set preview_mode_label = partner_preview.partner_mode_label %}
{% set preview_color_label = partner_preview.color_label %}
<div class="partner-preview__layout">
{% if preview_image %}
<div class="partner-preview__art">
{% if preview_href %}<a href="{{ preview_href }}" target="_blank" rel="noopener">{% endif %}
<img src="{{ preview_image }}" alt="{{ (preview_secondary or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" />
{% if preview_href %}</a>{% endif %}
</div>
{% endif %}
<div class="partner-preview__details">
<div class="partner-preview__header">{{ preview_mode_label }}{% if preview_color_label %} • {{ preview_color_label }}{% endif %}</div>
{% if preview_role %}
<div class="partner-preview__role">{{ preview_role }}</div>
{% endif %}
{% if preview_secondary %}
<div class="partner-preview__pairing">Pairing: {{ preview_primary }}{% if preview_secondary %} + {{ preview_secondary }}{% endif %}</div>
{% endif %}
{% if preview_themes %}
<div class="partner-preview__themes muted">Theme emphasis: {{ preview_themes|join(', ') }}</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
<div id="{{ partner_warning_id }}" data-partner-warnings="{{ prefix }}" data-warnings-json='{{ (partner_warnings or []) | tojson }}' style="background:#fff7e5; border:1px solid #f0c36d; border-radius:8px; padding:.75rem; font-size:12px; color:#7a4b02;" role="alert" aria-live="polite" aria-hidden="{{ 'false' if partner_warnings and partner_warnings|length else 'true' }}" {% if not (partner_warnings and partner_warnings|length) %}hidden{% endif %}>
{% if partner_warnings and partner_warnings|length %}
<strong>Warnings</strong>
<ul style="margin:.35rem 0 0 1.1rem;">
{% for warn in partner_warnings %}
<li>{{ warn }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
</fieldset>
<script>
(function(){
var prefix = '{{ prefix }}';
var controls = document.querySelector('[data-partner-controls="' + prefix + '"]');
if (!controls || controls.dataset.partnerInit === '1') return;
controls.dataset.partnerInit = '1';
var scope = controls.getAttribute('data-partner-scope') || prefix;
var selects = Array.prototype.slice.call(controls.querySelectorAll('[data-partner-select]'));
var clearBtn = document.querySelector('[data-partner-clear="' + prefix + '"]');
var optInput = document.querySelector('input[name="partner_auto_opt_out"][data-partner-auto-opt="' + prefix + '"]');
var autoToggle = document.querySelector('[data-partner-autotoggle="' + prefix + '"]');
var defaultPartner = autoToggle ? autoToggle.getAttribute('data-partner-default') : null;
var previewBox = document.querySelector('[data-partner-preview="' + prefix + '"]');
var warningsBox = document.querySelector('[data-partner-warnings="' + prefix + '"]');
var autoNoteBox = document.querySelector('[data-partner-autonote="' + prefix + '"]');
var autoNoteCopy = autoNoteBox ? autoNoteBox.querySelector('[data-partner-note-copy]') : null;
var primaryName = controls.getAttribute('data-primary-name') || '';
var fieldset = controls.closest('fieldset');
var partnerEnabledInput = fieldset ? fieldset.querySelector('input[name="partner_enabled"]') : null;
var selectionSourceInput = fieldset ? fieldset.querySelector('input[name="partner_selection_source"][data-partner-selection-source="' + prefix + '"]') : null;
var initialAutoNote = autoNoteBox ? (autoNoteBox.getAttribute('data-autonote') || '') : '';
function setSelectionSource(value){
if (!selectionSourceInput) return;
if (value && typeof value === 'string'){
selectionSourceInput.value = value;
} else {
selectionSourceInput.value = '';
}
}
function updateSuggestionsMeta(){
if (!suggestionsMeta || !suggestionsState){ return; }
var message = '';
var isError = false;
if (suggestionsState.loading){
message = 'Loading partner suggestions…';
} else if (suggestionsState.error){
message = suggestionsState.error;
isError = true;
} else if (suggestionsState.visible && suggestionsState.visible.length){
var shown = suggestionsState.visible.length;
if (suggestionsState.expanded && Array.isArray(suggestionsState.hidden)){
shown += suggestionsState.hidden.length;
}
if (suggestionsState.total && suggestionsState.total > 0){
message = 'Showing ' + shown + ' of ' + suggestionsState.total + ' suggestions.';
} else {
message = 'Suggestions generated from recent deck data.';
}
var meta = suggestionsState.metadata || {};
if (meta.generated_at){
message += ' Updated ' + meta.generated_at + '.';
}
} else if (suggestionsState.loaded){
message = 'No partner suggestions available for this commander yet.';
} else {
message = '';
}
suggestionsMeta.textContent = message;
suggestionsMeta.hidden = !message;
if (isError){
suggestionsMeta.style.color = '#a00';
} else {
suggestionsMeta.style.color = '';
}
}
function markSuggestionActive(){
if (!suggestionsList || !suggestionsState){ return; }
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
var bgSel = controls.querySelector('[data-partner-select="background"]');
var partnerValue = partnerSel && partnerSel.value ? partnerSel.value.toLowerCase() : '';
var backgroundValue = bgSel && bgSel.value ? bgSel.value.toLowerCase() : '';
var buttons = suggestionsList.querySelectorAll('[data-partner-suggestion]');
buttons.forEach(function(btn){
var mode = (btn.getAttribute('data-mode') || 'partner').toLowerCase();
var name = (btn.getAttribute('data-name') || '').toLowerCase();
var active = false;
if (mode === 'background'){
active = !!backgroundValue && backgroundValue === name;
} else {
active = !!partnerValue && partnerValue === name;
}
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
function renderSuggestions(){
if (!suggestionsBox || !suggestionsList || !suggestionsState){
return;
}
suggestionsList.innerHTML = '';
if (suggestionsState.error){
updateSuggestionsMeta();
if (suggestionsMoreButton){ suggestionsMoreButton.hidden = true; }
return;
}
var items = Array.isArray(suggestionsState.visible) ? suggestionsState.visible.slice() : [];
if (suggestionsState.expanded && Array.isArray(suggestionsState.hidden)){
items = items.concat(suggestionsState.hidden);
}
if (!items.length){
updateSuggestionsMeta();
if (suggestionsMoreButton){ suggestionsMoreButton.hidden = true; }
return;
}
items.forEach(function(item){
if (!item || !item.name) return;
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip partner-suggestion-chip';
btn.style.display = 'flex';
btn.style.flexDirection = 'column';
btn.style.alignItems = 'flex-start';
btn.style.gap = '2px';
btn.setAttribute('data-partner-suggestion', '1');
btn.setAttribute('data-mode', item.mode || 'partner');
btn.setAttribute('data-name', item.name);
if (item.mode_label){ btn.setAttribute('data-mode-label', item.mode_label); }
if (item.summary){ btn.setAttribute('data-summary', item.summary); }
if (typeof item.score_percent === 'number'){ btn.setAttribute('data-score', String(item.score_percent)); }
var titleParts = [];
if (item.summary){ titleParts.push(item.summary); }
if (Array.isArray(item.reasons) && item.reasons.length){ titleParts = titleParts.concat(item.reasons); }
if (titleParts.length){ btn.title = titleParts.join(' • '); }
var nameSpan = document.createElement('span');
nameSpan.className = 'partner-suggestion-chip__name';
nameSpan.textContent = item.name;
nameSpan.style.fontWeight = '600';
btn.appendChild(nameSpan);
var summaryText = '';
if (item.summary){ summaryText = item.summary; }
else if (typeof item.score_percent === 'number'){ summaryText = item.score_percent + '% match'; }
else if (item.mode_label){ summaryText = item.mode_label; }
if (summaryText){
var summarySpan = document.createElement('span');
summarySpan.className = 'partner-suggestion-chip__meta muted';
summarySpan.textContent = summaryText;
summarySpan.style.fontSize = '11px';
summarySpan.style.opacity = '0.85';
btn.appendChild(summarySpan);
}
suggestionsList.appendChild(btn);
});
if (suggestionsMoreButton){
if (!suggestionsState.expanded && Array.isArray(suggestionsState.hidden) && suggestionsState.hidden.length){
suggestionsMoreButton.hidden = false;
suggestionsMoreButton.textContent = 'Show more (' + suggestionsState.hidden.length + ')';
} else {
suggestionsMoreButton.hidden = true;
}
}
markSuggestionActive();
updateSuggestionsMeta();
}
function revealHiddenSuggestions(){
if (!suggestionsState){ return; }
if (Array.isArray(suggestionsState.hidden) && suggestionsState.hidden.length){
suggestionsState.expanded = true;
renderSuggestions();
} else {
fetchSuggestions({ includeHidden: true });
}
}
function collectSelectNames(kind){
var selector = '[data-partner-select="' + kind + '"]';
var sel = controls.querySelector(selector);
if (!sel){ return []; }
var values = [];
Array.prototype.forEach.call(sel.options, function(opt){
if (!opt || !opt.value){ return; }
values.push(opt.value);
});
return values;
}
function setSuggestionsLoading(flag){
if (!suggestionsState){ return; }
suggestionsState.loading = !!flag;
if (suggestionsRefreshButton){
if (flag){
suggestionsRefreshButton.classList.add('loading');
suggestionsRefreshButton.setAttribute('aria-busy', 'true');
} else {
suggestionsRefreshButton.classList.remove('loading');
suggestionsRefreshButton.removeAttribute('aria-busy');
}
}
updateSuggestionsMeta();
}
function fetchSuggestions(options){
if (!suggestionsBox || !suggestionsState){ return; }
if (typeof window === 'undefined' || typeof window.fetch !== 'function'){ return; }
if (!primaryName){ return; }
var includeHidden = !!(options && options.includeHidden);
try {
var endpoint = suggestionsBox.getAttribute('data-api-endpoint') || '/api/partner/suggestions';
var params = new URLSearchParams();
params.set('commander', primaryName);
var partnerNames = collectSelectNames('secondary');
var backgroundNames = collectSelectNames('background');
partnerNames.forEach(function(name){ params.append('partner', name); });
backgroundNames.forEach(function(name){ params.append('background', name); });
params.set('limit', '8');
params.set('visible_limit', '3');
var modeSet = {};
var modes = (options && Array.isArray(options.modes)) ? options.modes : ['partner_with', 'partner', 'doctor_companion', 'background'];
modes.forEach(function(mode){
var normalized = String(mode || '').trim();
if (!normalized){ return; }
var lower = normalized.toLowerCase();
if (modeSet[lower]){ return; }
modeSet[lower] = true;
params.append('mode', normalized);
});
if (includeHidden){ params.set('include_hidden', '1'); }
if (options && options.forceRefresh){ params.set('refresh', '1'); }
var fetchUrl = endpoint + (endpoint.indexOf('?') === -1 ? '?' : '&') + params.toString();
if (suggestionsAbort){
try { suggestionsAbort.abort(); } catch(_){ }
}
suggestionsAbort = new AbortController();
setSuggestionsLoading(true);
fetch(fetchUrl, {
method: 'GET',
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
signal: suggestionsAbort.signal,
}).then(function(resp){
if (!resp.ok){
throw new Error('suggestions request failed');
}
return resp.json();
}).then(function(data){
suggestionsAbort = null;
suggestionsState.error = '';
suggestionsState.loaded = true;
suggestionsState.metadata = data && data.metadata ? data.metadata : {};
suggestionsState.total = (data && typeof data.total === 'number') ? data.total : 0;
suggestionsState.visible = Array.isArray(data && data.visible) ? data.visible : [];
suggestionsState.hidden = Array.isArray(data && data.hidden) ? data.hidden : [];
suggestionsState.expanded = includeHidden && suggestionsState.hidden.length ? true : false;
suggestionsBox.setAttribute('data-loaded', '1');
suggestionsBox.setAttribute('data-error', '');
renderSuggestions();
}).catch(function(err){
if (err && err.name === 'AbortError'){ return; }
suggestionsState.error = 'Unable to load partner suggestions.';
suggestionsBox.setAttribute('data-error', suggestionsState.error);
renderSuggestions();
}).finally(function(){
setSuggestionsLoading(false);
});
} catch(_err){
suggestionsState.error = 'Unable to load partner suggestions.';
renderSuggestions();
}
}
var initialWarnings = [];
if (warningsBox && warningsBox.dataset.warningsJson){
try { initialWarnings = JSON.parse(warningsBox.dataset.warningsJson); }
catch(_){ initialWarnings = []; }
}
var serverPayload = null;
if (previewBox && previewBox.dataset.previewJson){
try{ serverPayload = JSON.parse(previewBox.dataset.previewJson); }
catch(_){ serverPayload = null; }
}
setServerPayload(serverPayload);
var suggestionsBox = document.querySelector('[data-partner-suggestions="' + prefix + '"]');
var suggestionsList = null;
var suggestionsMeta = null;
var suggestionsMoreButton = null;
var suggestionsRefreshButton = null;
var suggestionsAbort = null;
var suggestionsState = null;
function parseSuggestionsAttr(element, attr, fallback){
if (!element){ return fallback; }
var raw = element.getAttribute(attr);
if (!raw){ return fallback; }
try { return JSON.parse(raw); }
catch(_){ return fallback; }
}
if (suggestionsBox){
suggestionsList = suggestionsBox.querySelector('[data-partner-suggestions-list]');
suggestionsMeta = suggestionsBox.querySelector('[data-partner-suggestions-meta]');
suggestionsMoreButton = suggestionsBox.querySelector('[data-partner-suggestions-more="' + prefix + '"]');
suggestionsRefreshButton = suggestionsBox.querySelector('[data-partner-suggestions-refresh="' + prefix + '"]');
suggestionsState = {
visible: parseSuggestionsAttr(suggestionsBox, 'data-suggestions-json', []),
hidden: parseSuggestionsAttr(suggestionsBox, 'data-hidden-json', []),
metadata: parseSuggestionsAttr(suggestionsBox, 'data-metadata-json', {}),
total: parseInt(suggestionsBox.getAttribute('data-total') || '0', 10) || 0,
error: suggestionsBox.getAttribute('data-error') || '',
loaded: suggestionsBox.getAttribute('data-loaded') === '1',
expanded: false,
loading: false,
};
}
var modeLabels = {
'partner': 'Partner',
'partner_with': 'Partner With',
'doctor_companion': "Doctor & Companion",
'background': 'Choose a Background'
};
function buildCardImageUrl(name){
if (!name) return '';
return 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal';
}
function buildScryfallUrl(name){
if (!name) return '';
return 'https://scryfall.com/search?q=' + encodeURIComponent(name);
}
function defaultRoleForMode(mode){
if (!mode) return '';
switch(String(mode).toLowerCase()){
case 'background':
return 'Background';
case 'doctor_companion':
return "Doctor pairing";
default:
return 'Partner commander';
}
}
var previewAbort = null;
if (typeof window !== 'undefined' && !window.partnerPreviewState){
try { window.partnerPreviewState = {}; } catch(_){ }
}
function setPreviewState(detail){
if (typeof window === 'undefined') return;
if (!detail || typeof detail !== 'object') return;
var scopeKey = detail.scope || scope || prefix;
if (!scopeKey) return;
var store = window.partnerPreviewState || {};
store[scopeKey] = {
theme_tags: Array.isArray(detail.theme_tags) ? detail.theme_tags.slice() : [],
payload: detail.payload || null,
warnings: Array.isArray(detail.warnings) ? detail.warnings.slice() : [],
auto_note: detail.auto_note || null,
partner_mode: detail.partner_mode || null,
resolved_secondary: detail.resolved_secondary || null,
resolved_background: detail.resolved_background || null,
secondary_role_label: detail.secondary_role_label || detail.role_label || null,
};
try { window.partnerPreviewState = store; } catch(_){ }
}
function escapeHtml(str){
return String(str || "").replace(/[&<>"']/g, function(ch){
return ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]);
});
}
function clearPreview(){
if (!previewBox) return;
previewBox.hidden = true;
previewBox.innerHTML = '';
markSuggestionActive();
}
function renderPreview(payload){
if (!previewBox) return;
if (!payload){
clearPreview();
return;
}
var mode = payload.partner_mode || payload.mode || '';
var modeLabel = payload.partner_mode_label || payload.mode_label || modeLabels[mode] || 'Partner Mechanics';
var colorLabel = payload.color_label || '';
var secondaryName = payload.secondary_name || payload.name || '';
var primary = payload.primary_name || primaryName;
var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags : [];
var imageUrl = payload.secondary_image_url || payload.image_url || '';
if (!imageUrl && secondaryName){
imageUrl = buildCardImageUrl(secondaryName);
}
var scryfallUrl = payload.secondary_scryfall_url || payload.scryfall_url || '';
if (!scryfallUrl && secondaryName){
scryfallUrl = buildScryfallUrl(secondaryName);
}
var roleLabel = payload.secondary_role_label || payload.role_label || defaultRoleForMode(mode);
var html = '<div class="partner-preview__layout">';
var normalizedTags = Array.isArray(themes) ? themes.filter(function(tag){ return tag && String(tag).trim(); }).map(function(tag){ return String(tag).trim(); }) : [];
themes = normalizedTags;
var tagString = normalizedTags.length ? normalizedTags.join(', ') : '';
if (imageUrl){
var attrParts = [];
if (secondaryName){
attrParts.push('data-card-name="' + escapeHtml(secondaryName) + '"');
attrParts.push('data-original-name="' + escapeHtml(secondaryName) + '"');
}
if (roleLabel){
attrParts.push('data-role="' + escapeHtml(roleLabel) + '"');
}
if (tagString){
attrParts.push('data-tags="' + escapeHtml(tagString) + '"');
attrParts.push('data-overlaps="' + escapeHtml(tagString) + '"');
}
html += '<div class="partner-preview__art card-preview"' + (attrParts.length ? ' ' + attrParts.join(' ') : '') + '>';
if (scryfallUrl){
html += '<a href="' + escapeHtml(scryfallUrl) + '" target="_blank" rel="noopener">';
}
html += '<img src="' + escapeHtml(imageUrl) + '" alt="' + escapeHtml((secondaryName || 'Selected card') + ' card image') + '" loading="lazy" decoding="async" data-card-name="' + escapeHtml(secondaryName || '') + '"';
if (roleLabel){ html += ' data-role="' + escapeHtml(roleLabel) + '"'; }
if (tagString){ html += ' data-tags="' + escapeHtml(tagString) + '" data-overlaps="' + escapeHtml(tagString) + '"'; }
html += ' />';
if (scryfallUrl){
html += '</a>';
}
html += '</div>';
}
html += '<div class="partner-preview__details">';
html += '<div class="partner-preview__header">' + escapeHtml(modeLabel);
if (colorLabel){ html += ' • ' + escapeHtml(colorLabel); }
html += '</div>';
if (roleLabel){
html += '<div class="partner-preview__role">' + escapeHtml(roleLabel) + '</div>';
}
if (secondaryName){
var pairing = escapeHtml(primary);
if (pairing){ pairing += ' + '; }
html += '<div class="partner-preview__pairing">Pairing: ' + pairing + escapeHtml(secondaryName) + '</div>';
}
if (themes && themes.length){
html += '<div class="partner-preview__themes muted">Theme emphasis: ' + themes.map(escapeHtml).join(', ') + '</div>';
}
html += '</div></div>';
previewBox.innerHTML = html;
previewBox.hidden = false;
markSuggestionActive();
}
function setServerPayload(payload){
serverPayload = (payload && typeof payload === 'object') ? payload : null;
if (!previewBox) return;
if (serverPayload){
try {
previewBox.setAttribute('data-preview-json', JSON.stringify(serverPayload));
} catch(_){
previewBox.removeAttribute('data-preview-json');
}
} else {
previewBox.removeAttribute('data-preview-json');
}
}
function updateAutoNote(note){
if (!autoNoteBox) return;
var text = (note && String(note).trim()) || '';
autoNoteBox.setAttribute('aria-live', 'polite');
if (autoNoteCopy){
autoNoteCopy.textContent = text;
} else {
autoNoteBox.textContent = text;
}
autoNoteBox.hidden = !text;
autoNoteBox.setAttribute('aria-hidden', (!text).toString());
try { autoNoteBox.setAttribute('data-autonote', text); } catch(_){ }
}
function updateWarnings(list){
if (!warningsBox) return;
var warnings = Array.isArray(list) ? list.filter(function(msg){ return msg && String(msg).trim(); }) : [];
try { warningsBox.setAttribute('data-warnings-json', JSON.stringify(warnings)); } catch(_){ }
if (!warnings.length){
warningsBox.innerHTML = '';
warningsBox.hidden = true;
warningsBox.setAttribute('aria-hidden', 'true');
return;
}
var html = '<strong>Warnings</strong><ul style="margin:.35rem 0 0 1.1rem;">';
warnings.forEach(function(msg){
html += '<li>' + escapeHtml(String(msg)) + '</li>';
});
html += '</ul>';
warningsBox.innerHTML = html;
warningsBox.hidden = false;
warningsBox.setAttribute('aria-hidden', 'false');
}
function dispatchPreview(detail){
if (typeof document === 'undefined') return;
setPreviewState(detail);
try {
document.dispatchEvent(new CustomEvent('partner:preview', { detail: detail }));
} catch(_){ }
}
function requestPreviewUpdate(){
if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
if (!primaryName) return;
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
var bgSel = controls.querySelector('[data-partner-select="background"]');
var secondaryVal = partnerSel ? (partnerSel.value || '') : '';
var bgVal = bgSel ? (bgSel.value || '') : '';
var enabledVal = partnerEnabledInput ? (partnerEnabledInput.value || '') : '1';
if (previewAbort){
try { previewAbort.abort(); } catch(_){ }
}
previewAbort = new AbortController();
var formData = new FormData();
formData.append('commander', primaryName);
formData.append('partner_enabled', enabledVal || '1');
formData.append('secondary_commander', secondaryVal);
formData.append('background', bgVal);
formData.append('partner_auto_opt_out', optInput ? (optInput.value || '0') : '0');
formData.append('scope', scope || prefix);
formData.append('selection_source', selectionSourceInput ? (selectionSourceInput.value || '') : '');
fetch('/build/partner/preview', {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
signal: previewAbort.signal,
}).then(function(resp){
if (!resp.ok){ throw new Error('preview request failed'); }
return resp.json();
}).then(function(data){
previewAbort = null;
if (!data) return;
if (Object.prototype.hasOwnProperty.call(data, 'preview')){
setServerPayload(data.preview);
if (data.preview){ renderPreview(data.preview); }
else { clearPreview(); }
}
updateAutoNote(data && data.auto_note);
updateWarnings(data && data.warnings);
var evtDetail = {
scope: (data && data.scope) || scope || prefix,
payload: (data && data.preview) || null,
theme_tags: (data && data.theme_tags) || [],
warnings: (data && data.warnings) || [],
auto_note: (data && data.auto_note) || null,
partner_mode: (data && data.partner_mode) || null,
resolved_secondary: Object.prototype.hasOwnProperty.call(data || {}, 'resolved_secondary') ? (data && data.resolved_secondary) : undefined,
resolved_background: Object.prototype.hasOwnProperty.call(data || {}, 'resolved_background') ? (data && data.resolved_background) : undefined,
secondary_role_label: data && data.preview ? (data.preview.secondary_role_label || data.preview.role_label || null) : null,
};
dispatchPreview(evtDetail);
if (partnerSel && Object.prototype.hasOwnProperty.call(data, 'resolved_secondary')){
partnerSel.value = data.resolved_secondary || '';
}
if (bgSel && Object.prototype.hasOwnProperty.call(data, 'resolved_background')){
bgSel.value = data.resolved_background || '';
}
}).catch(function(err){
if (err && err.name === 'AbortError'){ return; }
previewAbort = null;
});
}
updateAutoNote(initialAutoNote);
updateWarnings(initialWarnings);
var initialHasPreview = !!(serverPayload && Array.isArray(serverPayload.theme_tags) && serverPayload.theme_tags.length);
if (initialHasPreview || initialWarnings.length || (initialAutoNote && initialAutoNote.trim())){
setTimeout(function(){
dispatchPreview({
scope: scope || prefix,
payload: serverPayload,
theme_tags: (serverPayload && serverPayload.theme_tags) || [],
warnings: initialWarnings,
auto_note: initialAutoNote || null,
partner_mode: serverPayload ? (serverPayload.partner_mode || serverPayload.mode || null) : null,
secondary_role_label: serverPayload ? (serverPayload.secondary_role_label || serverPayload.role_label || null) : null,
});
}, 0);
}
function renderFromServer(){
if (serverPayload){
renderPreview(serverPayload);
} else {
clearPreview();
}
}
function renderFromSelection(sel, modeOverride){
if (!sel){
if (serverPayload){ renderFromServer(); } else { clearPreview(); }
return;
}
var option = sel.options[sel.selectedIndex];
if (!option || !option.value){
if (serverPayload){ renderFromServer(); } else { clearPreview(); }
return;
}
var mode = modeOverride || option.getAttribute('data-pairing-mode') || 'partner';
var image = option.getAttribute('data-image-url') || '';
var link = option.getAttribute('data-scryfall-url') || '';
var role = option.getAttribute('data-role-label') || '';
if (!image){ image = buildCardImageUrl(option.value); }
if (!link){ link = buildScryfallUrl(option.value); }
if (!role){ role = defaultRoleForMode(mode); }
var payload = {
partner_mode: mode,
partner_mode_label: option.getAttribute('data-mode-label') || modeLabels[mode] || 'Partner Mechanics',
color_label: option.getAttribute('data-color-label') || '',
secondary_name: option.value,
primary_name: primaryName,
secondary_image_url: image,
secondary_scryfall_url: link,
secondary_role_label: role,
};
payload.secondary_role_label = role;
payload.theme_tags = payload.theme_tags || [];
renderPreview(payload);
}
function setOptOut(flag){
if (optInput){ optInput.value = flag ? '1' : '0'; }
if (autoToggle){
autoToggle.classList.toggle('active', !flag);
autoToggle.setAttribute('aria-pressed', (!flag).toString());
var label = flag ? 'Enable default partner' : 'Use default partner';
if (!flag && defaultPartner){ label += ' (' + defaultPartner + ')'; }
autoToggle.textContent = label;
}
markSuggestionActive();
}
function applySuggestionSelection(mode, name){
if (!name){ return; }
setSelectionSource('suggestion');
var normalizedMode = String(mode || '').toLowerCase();
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
var bgSel = controls.querySelector('[data-partner-select="background"]');
if (normalizedMode === 'background'){
if (bgSel){ bgSel.value = name; }
if (partnerSel){ partnerSel.value = ''; }
if (autoToggle){ setOptOut(true); }
if (bgSel){
renderFromSelection(bgSel, 'background');
requestPreviewUpdate();
} else {
renderFromServer();
}
} else {
if (partnerSel){ partnerSel.value = name; }
if (bgSel){ bgSel.value = ''; }
if (autoToggle){
syncBySelection();
} else if (partnerSel){
renderFromSelection(partnerSel);
requestPreviewUpdate();
} else {
renderFromServer();
}
}
markSuggestionActive();
}
function syncBySelection(){
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
if (!partnerSel || !autoToggle || !defaultPartner) return;
if (partnerSel.value && partnerSel.value.toLowerCase() === defaultPartner.toLowerCase()){
setOptOut(false);
renderFromSelection(partnerSel);
requestPreviewUpdate();
} else if (partnerSel.value) {
setOptOut(true);
renderFromSelection(partnerSel);
requestPreviewUpdate();
}
}
if (autoToggle){
autoToggle.addEventListener('click', function(){
var currentOptOut = optInput && optInput.value === '1';
if (currentOptOut){
setOptOut(false);
setSelectionSource('auto');
if (defaultPartner){
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
if (partnerSel){ partnerSel.value = defaultPartner; }
var bgSel = controls.querySelector('[data-partner-select="background"]');
if (bgSel){ bgSel.value = ''; }
renderFromSelection(partnerSel);
} else {
renderFromServer();
}
requestPreviewUpdate();
} else {
setOptOut(true);
setSelectionSource('');
selects.forEach(function(sel){
if (sel && sel.getAttribute('data-partner-select') === 'secondary'){
sel.value = '';
}
});
renderFromServer();
requestPreviewUpdate();
}
});
}
selects.forEach(function(sel){
sel.addEventListener('change', function(){
setSelectionSource('manual');
var key = sel.getAttribute('data-partner-select');
if (key === 'secondary' && sel.value){
var bgSel = controls.querySelector('[data-partner-select="background"]');
if (bgSel){ bgSel.value = ''; }
if (autoToggle){
syncBySelection();
} else {
renderFromSelection(sel);
requestPreviewUpdate();
}
markSuggestionActive();
return;
}
if (key === 'background' && sel.value){
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
if (partnerSel){ partnerSel.value = ''; }
if (autoToggle){ setOptOut(true); }
renderFromSelection(sel, 'background');
requestPreviewUpdate();
markSuggestionActive();
return;
}
if (!sel.value){
renderFromServer();
requestPreviewUpdate();
}
markSuggestionActive();
});
});
if (suggestionsState){
if (suggestionsList){
suggestionsList.addEventListener('click', function(evt){
var target = evt.target.closest('[data-partner-suggestion]');
if (!target){ return; }
evt.preventDefault();
var mode = target.getAttribute('data-mode') || 'partner';
var name = target.getAttribute('data-name') || '';
applySuggestionSelection(mode, name);
});
}
if (suggestionsMoreButton){
suggestionsMoreButton.addEventListener('click', function(){
revealHiddenSuggestions();
});
}
if (suggestionsRefreshButton){
suggestionsRefreshButton.addEventListener('click', function(){
fetchSuggestions({ includeHidden: suggestionsState.expanded, forceRefresh: true });
});
}
if (suggestionsState.visible && suggestionsState.visible.length){
renderSuggestions();
} else if (suggestionsState.error){
updateSuggestionsMeta();
} else {
fetchSuggestions();
}
}
if (clearBtn){
clearBtn.addEventListener('click', function(){
selects.forEach(function(sel){ if (sel){ sel.value = ''; } });
if (autoToggle){ setOptOut(true); }
setSelectionSource('');
renderFromServer();
requestPreviewUpdate();
markSuggestionActive();
});
}
if (typeof window !== 'undefined' && window.newDeckPartnerState){
try {
var restore = window.newDeckPartnerState;
var partnerSel = controls.querySelector('[data-partner-select="secondary"]');
var bgSel = controls.querySelector('[data-partner-select="background"]');
if (partnerSel && restore.secondary){ partnerSel.value = restore.secondary; }
if (bgSel && restore.background){ bgSel.value = restore.background; }
if (restore.enabled === false){ selects.forEach(function(sel){ if (sel){ sel.value = ''; } }); }
if (partnerSel && partnerSel.value){ renderFromSelection(partnerSel); }
else if (bgSel && bgSel.value){ renderFromSelection(bgSel, 'background'); }
delete window.newDeckPartnerState;
} catch(_){ }
}
if (optInput && optInput.value === '1'){
setOptOut(true);
renderFromServer();
} else {
setOptOut(!defaultPartner);
if (defaultPartner){ syncBySelection(); }
else if (serverPayload){ renderFromServer(); }
}
markSuggestionActive();
try {
var slot = document.getElementById('newdeck-tags-slot');
if (slot) slot.setAttribute('data-has-content', '1');
} catch(_){ }
})();
</script>
{% endif %}

View file

@ -1,5 +1,6 @@
<section>
{# Step phases removed #}
{% set partner_preview_payload = partner_preview if partner_preview else (combined_commander if combined_commander else None) %}
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander.name }}">
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
@ -8,6 +9,67 @@
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander_base }}" />
</a>
</aside>
{% if partner_preview_payload %}
{% set partner_secondary_name = partner_preview_payload.secondary_name %}
{% set partner_role_label = partner_preview_payload.secondary_role_label or 'Partner commander' %}
{% set partner_theme_tags = partner_preview_payload.theme_tags if partner_preview_payload.theme_tags else [] %}
{% set partner_tags_joined = partner_theme_tags|join(', ') %}
{% set partner_primary_name = partner_preview_payload.primary_name or commander.name %}
{% set partner_image_url = partner_preview_payload.secondary_image_url or partner_preview_payload.image_url %}
{% if partner_secondary_name %}
{% set partner_name_base = (partner_secondary_name.split(' - Synergy (')[0] if ' - Synergy (' in partner_secondary_name else partner_secondary_name) %}
{% else %}
{% set partner_name_base = partner_secondary_name %}
{% endif %}
{% if not partner_image_url and partner_name_base %}
{% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_name_base|urlencode ~ '&format=image&version=normal' %}
{% endif %}
{% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %}
{% if not partner_href and partner_name_base %}
{% set partner_href = 'https://scryfall.com/search?q=' ~ partner_name_base|urlencode %}
{% endif %}
<div class="commander-card partner-card" tabindex="0"
data-card-name="{{ partner_name_base }}"
data-original-name="{{ partner_secondary_name }}"
data-role="{{ partner_role_label }}"
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
{% if partner_name_base %}
<img src="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
width="320"
data-card-name="{{ partner_name_base }}"
data-original-name="{{ partner_secondary_name }}"
data-role="{{ partner_role_label }}"
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}
loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=large 672w"
sizes="(max-width: 900px) 100vw, 320px" />
{% else %}
<img src="{{ partner_image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" width="320" />
{% endif %}
{% if partner_href %}</a>{% endif %}
</div>
<div class="muted partner-label" style="margin-top:.35rem;">
{{ partner_role_label }}:
<span data-card-name="{{ partner_secondary_name }}"
data-original-name="{{ partner_secondary_name }}"
data-role="{{ partner_role_label }}"
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>{{ partner_secondary_name }}</span>
</div>
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
Pairing: {{ partner_primary_name }}{% if partner_secondary_name %} + {{ partner_secondary_name }}{% endif %}
</div>
{% if partner_preview_payload.color_label %}
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
Colors: {{ partner_preview_payload.color_label }}
</div>
{% endif %}
{% if partner_theme_tags %}
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
Theme emphasis: {{ partner_theme_tags|join(', ') }}
</div>
{% endif %}
{% endif %}
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
@ -40,29 +102,33 @@
</div>
<div id="combine-help-tip" class="muted" style="font-size:12px; margin:-.15rem 0 .5rem 0;">Tip: Choose OR for a stronger initial theme pool; switch to AND to tighten synergy.</div>
<div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></div>
{% if recommended and recommended|length %}
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
<div class="muted" style="font-size:12px;">Recommended</div>
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?">Why?</button>
<div id="tag-reco-block" data-has-reco="{% if recommended and recommended|length %}1{% else %}0{% endif %}" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>
<div id="tag-reco-header" style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
<div class="muted" style="font-size:12px;">Recommended</div>
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Why?</button>
</div>
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" data-default-reasons='{{ (recommended_reasons or {}) | tojson }}' style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
<div class="reco-why-title" style="font-size:12px; color:#222; margin-bottom:.5rem;">Why these themes? <span class="muted" style="color:#555;">Signals from oracle text, color identity, and your local build history.</span></div>
<ul class="reco-why-list" style="margin:.25rem 0; padding-left:1.1rem;">
{% if recommended and recommended|length %}
{% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'From this commander\'s theme list') %}
<li style="font-size:12px; color:#222; line-height:1.35;"><strong>{{ r }}</strong>: <span style="color:#333;">{{ tip }}</span></li>
{% endfor %}
{% endif %}
</ul>
</div>
<div id="tag-reco-list" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% if recommended and recommended|length %}
{% for r in recommended %}
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
{% endfor %}
{% endif %}
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button>
</div>
</div>
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
<div style="font-size:12px; color:#222; margin-bottom:.5rem;">Why these themes? <span class="muted" style="color:#555;">Signals from oracle text, color identity, and your local build history.</span></div>
<ul style="margin:.25rem 0; padding-left:1.1rem;">
{% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'From this commander\'s theme list') %}
<li style="font-size:12px; color:#222; line-height:1.35;"><strong>{{ r }}</strong>: <span style="color:#333;">{{ tip }}</span></li>
{% endfor %}
</ul>
</div>
<div id="tag-reco-list" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% for r in recommended %}
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
{% endfor %}
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
</div>
{% endif %}
<div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in tags %}
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
@ -74,6 +140,10 @@
{% endif %}
</fieldset>
{% set partner_id_prefix = 'step2' %}
{% set partner_scope = 'step2' %}
{% include "build/_partner_controls.html" %}
<fieldset>
<legend>Budget/Power Bracket</legend>
<div style="display:grid; gap:.5rem;">
@ -108,8 +178,8 @@
<script>
(function(){
var chipHost = document.getElementById('tag-chip-list');
var recoBlock = document.getElementById('tag-reco-block');
var recoHost = document.getElementById('tag-reco-list');
var selAll = document.getElementById('reco-select-all');
var resetBtn = document.getElementById('reset-tags');
var primary = document.getElementById('primary_tag');
var secondary = document.getElementById('secondary_tag');
@ -117,12 +187,52 @@
var tagMode = document.getElementById('tag_mode');
var countEl = document.getElementById('tag-count');
var orderEl = document.getElementById('tag-order');
var whyBtn = document.getElementById('reco-why');
var whyPanel = document.getElementById('reco-why-panel');
var commander = '{{ commander.name|e }}';
var clearPersisted = '{{ (clear_persisted|default(false)) and "1" or "0" }}' === '1';
if (!chipHost) return;
function escapeHtml(str){
return String(str || "").replace(/[&<>"']/g, function(ch){
return ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]);
});
}
function getSelectAllBtn(){ return document.getElementById('reco-select-all'); }
function getRecoHost(){ return document.getElementById('tag-reco-list'); }
function getRecoBlock(){ return document.getElementById('tag-reco-block'); }
function getWhyBtn(){ return document.getElementById('reco-why'); }
function getWhyPanel(){ return document.getElementById('reco-why-panel'); }
function originalRecommendedTags(){
var host = getRecoHost();
if (!host || !host.dataset.originalTags) return [];
try { var parsed = JSON.parse(host.dataset.originalTags); return Array.isArray(parsed) ? parsed : []; }
catch(_){ return []; }
}
function defaultReasonMap(){
var panel = getWhyPanel();
if (!panel || !panel.getAttribute('data-default-reasons')) return {};
try { var parsed = JSON.parse(panel.getAttribute('data-default-reasons')); return parsed && typeof parsed === 'object' ? parsed : {}; }
catch(_){ return {}; }
}
var previewScope = 'step2';
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
function readPartnerPreviewTags(){
if (typeof window === 'undefined') return [];
var store = window.partnerPreviewState;
if (!store) return [];
var state = store[previewScope];
if (!state) return [];
if (Array.isArray(state.theme_tags) && state.theme_tags.length){ return state.theme_tags.slice(); }
var payload = state.payload;
if (payload && Array.isArray(payload.theme_tags)){ return payload.theme_tags.slice(); }
return [];
}
function getSelected(){
var arr = [];
if (primary && primary.value) arr.push(primary.value);
@ -231,91 +341,196 @@
});
if (resetBtn) resetBtn.addEventListener('click', function(){ setSelected([]); updateChipsState(); });
// attach handlers to existing chips
Array.prototype.forEach.call(chipHost.querySelectorAll('button.chip'), function(btn){
chipHost.addEventListener('click', function(e){
var btn = e.target.closest('button.chip');
if (!btn || !chipHost.contains(btn)) return;
var t = btn.dataset.tag || '';
btn.addEventListener('click', function(){ toggleTag(t); });
btn.addEventListener('keydown', function(e){
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleTag(t); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
var chips = Array.prototype.slice.call(chipHost.querySelectorAll('button.chip'));
var ix = chips.indexOf(e.currentTarget);
var next = (e.key === 'ArrowRight') ? chips[Math.min(ix+1, chips.length-1)] : chips[Math.max(ix-1, 0)];
if (next) { try { next.focus(); } catch(_){ } }
}
});
if (!t) return;
toggleTag(t);
});
chipHost.addEventListener('keydown', function(e){
var btn = e.target.closest('button.chip');
if (!btn || !chipHost.contains(btn)) return;
var t = btn.dataset.tag || '';
if (!t) return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggleTag(t);
} else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
var chips = Array.prototype.slice.call(chipHost.querySelectorAll('button.chip'));
var ix = chips.indexOf(btn);
if (ix >= 0){
var next = (e.key === 'ArrowRight') ? chips[Math.min(ix+1, chips.length-1)] : chips[Math.max(ix-1, 0)];
if (next && next.focus){
try { next.focus(); } catch(_){ }
}
}
}
});
// attach handlers to recommended chips and select-all
if (recoHost){
Array.prototype.forEach.call(recoHost.querySelectorAll('button.chip-reco'), function(btn){
var t = btn.dataset.tag || '';
btn.addEventListener('click', function(){ toggleTag(t); });
});
if (selAll){
selAll.addEventListener('click', function(){
recoHost.addEventListener('click', function(e){
var btn = e.target.closest('button');
if (!btn || !recoHost.contains(btn)) return;
if (btn.id === 'reco-select-all'){
e.preventDefault();
try {
var sel = getSelected();
var recs = Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean);
var combined = sel.slice();
recs.forEach(function(t){ if (combined.indexOf(t) === -1) combined.push(t); });
combined = combined.slice(-3); // keep last 3
combined = combined.slice(-3);
setSelected(combined);
updateChipsState();
updateSelectAllState();
} catch(_){ }
});
}
// Why recommended panel toggle
var whyBtn = document.getElementById('reco-why');
var whyPanel = document.getElementById('reco-why-panel');
function setWhy(open){
if (!whyBtn || !whyPanel) return;
whyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
whyPanel.style.display = open ? 'block' : 'none';
whyPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
}
if (whyBtn && whyPanel){
whyBtn.addEventListener('click', function(e){
e.stopPropagation();
return;
}
if (btn.classList.contains('chip-reco')){
var t = btn.dataset.tag || '';
if (t){ toggleTag(t); }
}
});
}
function toggleWhyPanel(open){
if (!whyBtn || !whyPanel) return;
whyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
whyPanel.style.display = open ? 'block' : 'none';
whyPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
}
if (whyBtn && whyPanel){
whyBtn.addEventListener('click', function(e){
e.stopPropagation();
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
toggleWhyPanel(!isOpen);
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){ } }
});
document.addEventListener('click', function(e){
try {
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
setWhy(!isOpen);
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){} }
});
document.addEventListener('click', function(e){
try {
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
if (!isOpen) return;
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
setWhy(false);
} catch(_){}
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape'){ setWhy(false); }
});
if (!isOpen) return;
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
toggleWhyPanel(false);
} catch(_){ }
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape'){ toggleWhyPanel(false); }
});
}
function refreshWhyPanel(partnerTags){
var panel = getWhyPanel();
if (!panel) return;
var list = panel.querySelector('.reco-why-list');
if (!list) return;
var reasons = defaultReasonMap();
var base = originalRecommendedTags();
var seen = new Set();
var items = [];
base.forEach(function(tag){
var value = String(tag || '').trim();
if (!value) return;
var key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
var tip = reasons && reasons[value] ? reasons[value] : 'From this commander\'s theme list';
items.push('<li style="font-size:12px; color:#222; line-height:1.35;"><strong>' + escapeHtml(value) + '</strong>: <span style="color:#333;">' + escapeHtml(tip) + '</span></li>');
});
(Array.isArray(partnerTags) ? partnerTags : []).forEach(function(tag){
var value = String(tag || '').trim();
if (!value) return;
var key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
items.push('<li style="font-size:12px; color:#222; line-height:1.35;"><strong>' + escapeHtml(value) + '</strong>: <span style="color:#333;">Synergizes with selected partner pairing</span></li>');
});
list.innerHTML = items.join('');
if (!items.length){
toggleWhyPanel(false);
}
}
function updatePartnerRecommendations(tags){
var host = getRecoHost();
var block = getRecoBlock();
if (!host || !block) return;
var selectAllBtn = getSelectAllBtn();
Array.prototype.slice.call(host.querySelectorAll('button.partner-suggestion')).forEach(function(btn){ btn.remove(); });
var unique = [];
var seen = new Set();
(Array.isArray(tags) ? tags : []).forEach(function(tag){
var value = String(tag || '').trim();
if (!value) return;
var key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
unique.push(value);
});
var insertBefore = selectAllBtn && selectAllBtn.parentElement === host ? selectAllBtn : null;
unique.forEach(function(tag){
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip chip-reco partner-suggestion';
btn.dataset.tag = tag;
btn.setAttribute('aria-pressed', getSelected().indexOf(tag) >= 0 ? 'true' : 'false');
btn.title = 'Synergizes with selected partner pairing';
btn.textContent = '★ ' + tag;
if (insertBefore){ host.insertBefore(btn, insertBefore); }
else { host.appendChild(btn); }
});
var hasAny = host.querySelectorAll('button.chip-reco').length > 0;
block.style.display = hasAny ? '' : 'none';
block.setAttribute('data-has-reco', hasAny ? '1' : '0');
var btnEl = getWhyBtn();
if (btnEl){ btnEl.style.display = hasAny ? '' : 'none'; }
if (selectAllBtn){ selectAllBtn.style.display = hasAny ? '' : 'none'; }
refreshWhyPanel(unique);
updateSelectAllState();
updateChipsState();
}
function updateSelectAllState(){
try {
if (!selAll) return;
var selAllBtn = getSelectAllBtn();
if (!selAllBtn) return;
var sel = getSelected();
var recs = recoHost ? Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean) : [];
var host = getRecoHost();
var recs = host ? Array.prototype.slice.call(host.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean) : [];
var unselected = recs.filter(function(t){ return sel.indexOf(t) === -1; });
var atCap = sel.length >= 3;
var noNew = unselected.length === 0;
var disable = atCap || noNew;
selAll.disabled = disable;
selAll.setAttribute('aria-disabled', disable ? 'true' : 'false');
selAllBtn.disabled = disable;
selAllBtn.setAttribute('aria-disabled', disable ? 'true' : 'false');
if (disable){
selAll.title = atCap ? 'Already have 3 themes selected' : 'All recommended already selected';
selAllBtn.title = atCap ? 'Already have 3 themes selected' : 'All recommended already selected';
} else {
selAll.title = 'Add recommended up to 3';
selAllBtn.title = 'Add recommended up to 3';
}
} catch(_){ }
}
document.addEventListener('partner:preview', function(evt){
var detail = (evt && evt.detail) || {};
if (detail.scope && detail.scope !== previewScope) return;
var tags = Array.isArray(detail.theme_tags) && detail.theme_tags.length ? detail.theme_tags : [];
if (!tags.length && detail.payload && Array.isArray(detail.payload.theme_tags)){
tags = detail.payload.theme_tags;
}
updatePartnerRecommendations(tags);
});
var initialPartnerTags = readPartnerPreviewTags();
if (initialPartnerTags.length){
updatePartnerRecommendations(initialPartnerTags);
} else {
refreshWhyPanel([]);
}
// initial: set from template-selected values, then maybe load persisted if none
updateChipsState();
loadPersisted();

View file

@ -1,3 +1,25 @@
{% from 'partials/_macros.html' import color_identity %}
{% set combined = combined_commander if combined_commander else {} %}
{% set display_commander_name = commander_display_name or commander %}
{% if not display_commander_name %}
{% set display_commander_name = commander %}
{% endif %}
{% set color_identity_list = commander_color_identity if commander_color_identity else [] %}
{% if not color_identity_list and summary and summary.colors %}
{% set color_identity_list = summary.colors %}
{% endif %}
{% set color_label = commander_color_label %}
{% if not color_label and color_identity_list %}
{% set color_label = color_identity_list|join(' / ') %}
{% endif %}
{% if not color_label and (color_identity_list|length == 0) and combined %}
{% set color_label = 'Colorless (C)' %}
{% endif %}
{% set display_tags_source = deck_theme_tags if deck_theme_tags else (tags if tags else commander_combined_tags) %}
{% set hover_tags_source = deck_theme_tags if deck_theme_tags else commander_combined_tags %}
{% set hover_tags_joined = hover_tags_source|join(', ') %}
{% set display_tags = display_tags_source if display_tags_source else [] %}
{% set show_color_identity = color_label or (color_identity_list|length > 0) %}
<section>
{# Step phases removed #}
<div class="two-col two-col-left-rail">
@ -9,16 +31,16 @@
data-card-name="{{ commander_base }}"
data-original-name="{{ commander }}"
data-role="{{ commander_role_label or 'Commander' }}"
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image"
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image"
width="320"
data-card-name="{{ commander_base }}"
data-original-name="{{ commander }}"
data-role="{{ commander_role_label or 'Commander' }}"
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}
@ -30,11 +52,67 @@
Commander: <span data-card-name="{{ commander }}"
data-original-name="{{ commander }}"
data-role="{{ commander_role_label or 'Commander' }}"
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ commander }}</span>
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ display_commander_name or commander }}</span>
</div>
{% endif %}
{% if combined and combined.secondary_name %}
{% set partner_secondary_name = combined.secondary_name %}
{% set partner_role_label = combined.secondary_role_label or ('Background' if (combined.partner_mode == 'background') else 'Partner commander') %}
{% set partner_theme_tags = combined.theme_tags if combined.theme_tags else [] %}
{% set partner_tags_joined = partner_theme_tags|join(', ') %}
{% if partner_secondary_name %}
{% set partner_name_base = (partner_secondary_name.split(' - Synergy (')[0] if ' - Synergy (' in partner_secondary_name else partner_secondary_name) %}
{% else %}
{% set partner_name_base = partner_secondary_name %}
{% endif %}
{% set partner_href = combined.secondary_scryfall_url or combined.scryfall_url %}
{% if not partner_href and partner_name_base %}
{% set partner_href = 'https://scryfall.com/search?q=' ~ partner_name_base|urlencode %}
{% endif %}
<div class="commander-card partner-card" tabindex="0"
data-card-name="{{ partner_name_base }}"
data-original-name="{{ partner_secondary_name }}"
data-role="{{ partner_role_label }}"
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
{% if partner_name_base %}
<img src="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
width="320"
data-card-name="{{ partner_name_base }}"
data-original-name="{{ partner_secondary_name }}"
data-role="{{ partner_role_label }}"
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}
loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=large 672w"
sizes="(max-width: 900px) 100vw, 320px" />
{% else %}
<img src="{{ combined.secondary_image_url or combined.image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" width="320" />
{% endif %}
{% if partner_href %}</a>{% endif %}
</div>
<div class="muted partner-label" style="margin-top:.35rem;">
{{ partner_role_label }}:
<span data-card-name="{{ partner_secondary_name }}"
data-original-name="{{ partner_secondary_name }}"
data-role="{{ partner_role_label }}"
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>{{ partner_secondary_name }}</span>
</div>
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
Pairing: {{ combined.primary_name or display_commander_name or commander }}{% if partner_secondary_name %} + {{ partner_secondary_name }}{% endif %}
</div>
{% if combined.color_label %}
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
Colors: {{ combined.color_label }}
</div>
{% endif %}
{% if partner_theme_tags %}
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
Theme emphasis: {{ partner_theme_tags|join(', ') }}
</div>
{% endif %}
{% endif %}
{% if status and status.startswith('Build complete') %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
@ -63,15 +141,21 @@
data-card-name="{{ commander }}"
data-original-name="{{ commander }}"
data-role="{{ commander_role_label or 'Commander' }}"
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ commander }}</strong>
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ display_commander_name or commander }}</strong>
{% else %}
<strong>None selected</strong>
{% endif %}
</p>
<p>Tags: {{ deck_theme_tags|default([])|join(', ') }}</p>
{% if show_color_identity %}
<div class="muted" style="display:flex; align-items:center; gap:.35rem; margin:-.35rem 0 .5rem 0;">
{{ color_identity(color_identity_list, is_colorless=(color_identity_list|length == 0), aria_label=color_label or '', title_text=color_label or '') }}
<span>{{ color_label }}</span>
</div>
{% endif %}
<p>Tags: {% if display_tags %}{{ display_tags|join(', ') }}{% else %}—{% endif %}</p>
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">

View file

@ -5,12 +5,14 @@
{% if display_name %}
<div><strong>{{ display_name }}</strong></div>
{% endif %}
{% set hover_tags_source = deck_theme_tags if deck_theme_tags else (tags if tags else commander_combined_tags) %}
{% set hover_tags_joined = hover_tags_source|join(', ') %}
<div class="muted">Commander:
<strong class="commander-hover"
data-card-name="{{ commander }}"
data-original-name="{{ commander }}"
data-role="{{ commander_role_label }}"
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ commander }}</strong>
@ -29,7 +31,7 @@
data-card-name="{{ commander_base }}"
data-original-name="{{ commander }}"
data-role="{{ commander_role_label }}"
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>
@ -38,7 +40,7 @@
data-card-name="{{ commander_base }}"
data-original-name="{{ commander }}"
data-role="{{ commander_role_label }}"
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}
@ -47,7 +49,7 @@
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}"
data-original-name="{{ commander }}"
data-role="{{ commander_role_label }}"
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ commander }}</span></div>

View file

@ -71,6 +71,13 @@
{% endif %}
<div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Dual-Commander diagnostics</h3>
<div class="muted" style="margin-bottom:.35rem;">Latest partner, partner-with, doctor, and background pairings with color sources.</div>
<div id="partnerMetricsSummary" class="muted">Loading partner metrics…</div>
<div id="partnerMetricsModes" class="muted" style="margin-top:.5rem;"></div>
<div id="partnerColorSources" style="margin-top:.5rem;"></div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Performance (local)</h3>
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
@ -436,6 +443,160 @@
.catch(function(){ dfcMetricsEl.textContent = 'MDFC metrics unavailable'; });
}
loadDfcMetrics();
var partnerSummaryEl = document.getElementById('partnerMetricsSummary');
var partnerModesEl = document.getElementById('partnerMetricsModes');
var partnerSourcesEl = document.getElementById('partnerColorSources');
function escapeHtml(str){
return String(str == null ? '' : str).replace(/[&<>"']/g, function(ch){
return ({"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;"}[ch]) || ch;
});
}
function labelForPartnerRole(role){
var key = role == null ? '' : String(role).toLowerCase();
var map = {
'primary': 'Primary',
'partner': 'Partner commander',
'partner_with': 'Partner With',
'background': 'Background',
'companion': "Doctor's Companion",
'doctor_companion': "Doctor's Companion",
'doctor': 'Doctor',
'secondary': 'Secondary',
};
if (map[key]) return map[key];
if (!key) return '';
return key.replace(/_/g, ' ').replace(/\b\w/g, function(ch){ return ch.toUpperCase(); });
}
function labelForPartnerMode(mode){
var key = mode == null ? 'none' : String(mode).toLowerCase();
var map = {
'none': 'Single commander',
'partner': 'Partner',
'partner_with': 'Partner With',
'background': 'Choose a Background',
'doctor_companion': "Doctor & Companion",
'doctor': 'Doctor',
};
return map[key] || labelForPartnerRole(key) || key;
}
function buildModeCountsHtml(modeCounts, total){
var html = '<div><strong>Total pairings observed:</strong> ' + String(total || 0) + '</div>';
var keys = Object.keys(modeCounts || {}).filter(function(k){ return Number(modeCounts[k] || 0) > 0; });
if (keys.length){
var parts = keys.sort().map(function(k){
return labelForPartnerMode(k) + ': ' + String(modeCounts[k]);
});
html += '<div style="font-size:12px;">Mode breakdown: ' + parts.join(' · ') + '</div>';
}
return html;
}
function renderPartnerMetrics(payload){
if (!partnerSummaryEl) return;
try{
if (!payload || payload.ok !== true){
partnerSummaryEl.textContent = 'Partner metrics unavailable';
if (partnerModesEl) partnerModesEl.textContent = '';
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
return;
}
var metrics = payload.metrics || {};
var total = Number(metrics.total_pairs || 0);
var modeCounts = metrics.mode_counts || {};
var last = metrics.last_summary || null;
var updated = metrics.last_updated || '';
if (!total || !last){
partnerSummaryEl.textContent = 'No partner/background builds recorded yet.';
if (partnerModesEl) partnerModesEl.innerHTML = buildModeCountsHtml(modeCounts, total);
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
return;
}
var primary = last.primary != null ? String(last.primary) : '';
var secondary = last.secondary != null ? String(last.secondary) : '';
if (!primary && Array.isArray(last.names) && last.names.length){ primary = String(last.names[0] || ''); }
if (!secondary && Array.isArray(last.names) && last.names.length > 1){ secondary = String(last.names[1] || ''); }
var header = '<div><strong>Latest pairing:</strong> ' + escapeHtml(primary || '—');
if (secondary){ header += ' + ' + escapeHtml(secondary); }
header += '</div>';
header += '<div><strong>Mode:</strong> ' + escapeHtml(labelForPartnerMode(last.partner_mode)) + '</div>';
var colorLabel = last.color_label != null ? String(last.color_label) : '';
var colorCode = last.color_code != null ? String(last.color_code) : '';
var colors = Array.isArray(last.color_identity) ? last.color_identity.filter(Boolean).map(String).join(' / ') : '';
if (colorLabel || colorCode || colors){
var labelText = colorLabel || colors || colorCode;
var extra = (!colorLabel && colorCode && colorCode !== labelText) ? ' (' + escapeHtml(colorCode) + ')' : '';
if (colorLabel && colorCode && colorLabel.indexOf(colorCode) === -1){ extra = ' (' + escapeHtml(colorCode) + ')'; }
header += '<div><strong>Colors:</strong> ' + escapeHtml(labelText) + extra + '</div>';
}
if (updated){
header += '<div style="font-size:11px; opacity:0.75;">Last updated: ' + escapeHtml(updated) + '</div>';
}
partnerSummaryEl.innerHTML = header;
if (partnerModesEl){
partnerModesEl.innerHTML = buildModeCountsHtml(modeCounts, total);
}
if (partnerSourcesEl){
var sources = Array.isArray(last.color_sources) ? last.color_sources : [];
if (!sources.length){
partnerSourcesEl.innerHTML = '<div class="muted">No color source breakdown recorded.</div>';
} else {
var html = '<div><strong>Color sources</strong></div>';
html += '<ul style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.25rem;">';
sources.forEach(function(entry){
var color = entry && entry.color != null ? String(entry.color) : '?';
var providers = Array.isArray(entry && entry.providers) ? entry.providers : [];
var providerParts = providers.map(function(provider){
var name = provider && provider.name != null ? String(provider.name) : 'Unknown';
var roleLabel = labelForPartnerRole(provider && provider.role);
if (roleLabel){
return escapeHtml(name) + ' [' + escapeHtml(roleLabel) + ']';
}
return escapeHtml(name);
});
if (!providerParts.length){ providerParts.push('—'); }
html += '<li class="muted"><span class="chip" style="display:inline-flex; align-items:center; gap:.25rem;"><span class="dot" style="background: var(--border);"></span> ' + escapeHtml(color) + '</span> ' + providerParts.join(', ') + '</li>';
});
html += '</ul>';
var delta = last.color_delta || {};
try{
var deltaParts = [];
var added = Array.isArray(delta.added) ? delta.added.filter(Boolean) : [];
var removed = Array.isArray(delta.removed) ? delta.removed.filter(Boolean) : [];
if (added.length){ deltaParts.push('Added ' + added.map(escapeHtml).join(', ')); }
if (removed.length){ deltaParts.push('Removed ' + removed.map(escapeHtml).join(', ')); }
if (deltaParts.length){
html += '<div class="muted" style="font-size:12px; margin-top:.35rem;">' + deltaParts.join(' · ') + '</div>';
}
}catch(_){ }
partnerSourcesEl.innerHTML = html;
}
}
}catch(_){
partnerSummaryEl.textContent = 'Partner metrics unavailable';
if (partnerModesEl) partnerModesEl.textContent = '';
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
}
}
function loadPartnerMetrics(){
if (!partnerSummaryEl) return;
partnerSummaryEl.textContent = 'Loading partner metrics…';
fetch('/status/partner_metrics', { cache: 'no-store' })
.then(function(resp){
if (resp.status === 404){
partnerSummaryEl.textContent = 'Diagnostics disabled (partner metrics unavailable)';
if (partnerModesEl) partnerModesEl.textContent = '';
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
return null;
}
return resp.json();
})
.then(function(data){ if (data) renderPartnerMetrics(data); })
.catch(function(){
partnerSummaryEl.textContent = 'Partner metrics unavailable';
if (partnerModesEl) partnerModesEl.textContent = '';
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
});
}
loadPartnerMetrics();
// Theme status and reset
try{
var tEl = document.getElementById('themeSummary');