mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
web: DRY Step 5 and alternatives (partial+macro), centralize start_ctx/owned_set, adopt builder_*
This commit is contained in:
parent
fe9aabbce9
commit
014bcc37b7
24 changed files with 1200 additions and 766 deletions
25
code/web/services/alts_utils.py
Normal file
25
code/web/services/alts_utils.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Tuple
|
||||
import time as _t
|
||||
|
||||
# Lightweight in-memory TTL cache for alternatives fragments
|
||||
_ALTS_CACHE: Dict[Tuple[str, str, bool], Tuple[float, str]] = {}
|
||||
_ALTS_TTL_SECONDS = 60.0
|
||||
|
||||
|
||||
def get_cached(key: tuple[str, str, bool]) -> str | None:
|
||||
try:
|
||||
ts, html = _ALTS_CACHE.get(key, (0.0, ""))
|
||||
if ts and (_t.time() - ts) < _ALTS_TTL_SECONDS:
|
||||
return html
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def set_cached(key: tuple[str, str, bool], html: str) -> None:
|
||||
try:
|
||||
_ALTS_CACHE[key] = (_t.time(), html)
|
||||
except Exception:
|
||||
pass
|
||||
265
code/web/services/build_utils.py
Normal file
265
code/web/services/build_utils.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from fastapi import Request
|
||||
from ..services import owned_store
|
||||
from . import orchestrator as orch
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, include_locks: bool = True) -> Dict[str, Any]:
|
||||
"""Assemble the common Step 5 template context from session.
|
||||
|
||||
Includes commander/tags/bracket/values, ownership flags, owned_set, locks, replace_mode,
|
||||
combo preferences, and static game_changers. Caller can layer run-specific results.
|
||||
"""
|
||||
ctx: Dict[str, Any] = {
|
||||
"request": request,
|
||||
"commander": sess.get("commander"),
|
||||
"tags": sess.get("tags", []),
|
||||
"bracket": sess.get("bracket"),
|
||||
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"owned_set": owned_set(),
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
}
|
||||
if include_name:
|
||||
ctx["name"] = sess.get("custom_export_base")
|
||||
if include_locks:
|
||||
ctx["locks"] = list(sess.get("locks", []))
|
||||
return ctx
|
||||
|
||||
|
||||
def owned_set() -> set[str]:
|
||||
"""Return lowercase owned card names with trimming for robust matching."""
|
||||
try:
|
||||
return {str(n).strip().lower() for n in owned_store.get_names()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def owned_names() -> list[str]:
|
||||
"""Return raw owned card names from the store (original casing)."""
|
||||
try:
|
||||
return list(owned_store.get_names())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[str, Any]:
|
||||
"""Create a staged build context from the current session selections.
|
||||
|
||||
Pulls commander, tags, bracket, ideals, tag_mode, ownership flags, locks, custom name,
|
||||
multi-copy selection, and combo preferences from the session and starts a build context.
|
||||
"""
|
||||
opts = orch.bracket_options()
|
||||
default_bracket = (opts[0]["level"] if opts else 1)
|
||||
bracket_val = sess.get("bracket")
|
||||
try:
|
||||
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
|
||||
except Exception:
|
||||
safe_bracket = int(default_bracket)
|
||||
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||
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
|
||||
ctx = orch.start_build_ctx(
|
||||
commander=sess.get("commander"),
|
||||
tags=sess.get("tags", []),
|
||||
bracket=safe_bracket,
|
||||
ideals=ideals_val,
|
||||
tag_mode=sess.get("tag_mode", "AND"),
|
||||
use_owned_only=use_owned,
|
||||
prefer_owned=prefer,
|
||||
owned_names=owned_names_list,
|
||||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
if set_on_session:
|
||||
sess["build_ctx"] = ctx
|
||||
return ctx
|
||||
|
||||
|
||||
def step5_ctx_from_result(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
res: dict,
|
||||
*,
|
||||
status_text: Optional[str] = None,
|
||||
show_skipped: bool = False,
|
||||
include_name: bool = True,
|
||||
include_locks: bool = True,
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a Step 5 context by merging base session data with a build stage result dict.
|
||||
|
||||
res is expected to be the dict returned from orchestrator.run_stage or similar with keys like
|
||||
label, log_delta, added_cards, idx, total, csv_path, txt_path, summary, etc.
|
||||
"""
|
||||
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
|
||||
done = bool(res.get("done"))
|
||||
ctx: Dict[str, Any] = {
|
||||
**base,
|
||||
"status": status_text,
|
||||
"stage_label": res.get("label"),
|
||||
"log": res.get("log_delta", ""),
|
||||
"added_cards": res.get("added_cards", []),
|
||||
"i": res.get("idx"),
|
||||
"n": res.get("total"),
|
||||
"csv_path": res.get("csv_path") if done else None,
|
||||
"txt_path": res.get("txt_path") if done else None,
|
||||
"summary": res.get("summary") if done else None,
|
||||
"show_skipped": bool(show_skipped),
|
||||
"total_cards": res.get("total_cards"),
|
||||
"added_total": res.get("added_total"),
|
||||
"mc_adjustments": res.get("mc_adjustments"),
|
||||
"clamped_overflow": res.get("clamped_overflow"),
|
||||
"mc_summary": res.get("mc_summary"),
|
||||
"skipped": bool(res.get("skipped")),
|
||||
}
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
||||
|
||||
def step5_error_ctx(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
message: str,
|
||||
*,
|
||||
include_name: bool = True,
|
||||
include_locks: bool = True,
|
||||
status_text: str = "Error",
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a normalized Step 5 context for error states.
|
||||
|
||||
Provides all keys expected by the _step5.html template so the UI stays consistent
|
||||
even when a build can't start or a stage fails. The error message is placed in `log`.
|
||||
"""
|
||||
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
|
||||
ctx: Dict[str, Any] = {
|
||||
**base,
|
||||
"status": status_text,
|
||||
"stage_label": None,
|
||||
"log": str(message),
|
||||
"added_cards": [],
|
||||
"i": None,
|
||||
"n": None,
|
||||
"csv_path": None,
|
||||
"txt_path": None,
|
||||
"summary": None,
|
||||
"show_skipped": False,
|
||||
"total_cards": None,
|
||||
"added_total": 0,
|
||||
"skipped": False,
|
||||
}
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
||||
|
||||
def step5_empty_ctx(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
*,
|
||||
include_name: bool = True,
|
||||
include_locks: bool = True,
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a baseline Step 5 context with empty stage data.
|
||||
|
||||
Used for GET /step5 and reset-stage flows to render the screen before any stage is run.
|
||||
"""
|
||||
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
|
||||
ctx: Dict[str, Any] = {
|
||||
**base,
|
||||
"status": None,
|
||||
"stage_label": None,
|
||||
"log": None,
|
||||
"added_cards": [],
|
||||
"i": None,
|
||||
"n": None,
|
||||
"total_cards": None,
|
||||
"added_total": 0,
|
||||
"show_skipped": False,
|
||||
"skipped": False,
|
||||
}
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
||||
|
||||
def builder_present_names(builder: Any) -> set[str]:
|
||||
"""Return a lowercase set of names currently present in the builder/deck structures.
|
||||
|
||||
Safely probes a variety of attributes used across different builder implementations.
|
||||
"""
|
||||
present: set[str] = set()
|
||||
def _add_names(x: Any) -> None:
|
||||
try:
|
||||
if not x:
|
||||
return
|
||||
if isinstance(x, dict):
|
||||
for k, v in x.items():
|
||||
if isinstance(k, str) and k.strip():
|
||||
present.add(k.strip().lower())
|
||||
elif isinstance(v, dict) and v.get('name'):
|
||||
present.add(str(v.get('name')).strip().lower())
|
||||
elif isinstance(x, (list, tuple, set)):
|
||||
for item in x:
|
||||
if isinstance(item, str) and item.strip():
|
||||
present.add(item.strip().lower())
|
||||
elif isinstance(item, dict) and item.get('name'):
|
||||
present.add(str(item.get('name')).strip().lower())
|
||||
else:
|
||||
try:
|
||||
nm = getattr(item, 'name', None)
|
||||
if isinstance(nm, str) and nm.strip():
|
||||
present.add(nm.strip().lower())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if builder is None:
|
||||
return present
|
||||
for attr in (
|
||||
'current_deck', 'deck', 'final_deck', 'final_cards',
|
||||
'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck',
|
||||
):
|
||||
_add_names(getattr(builder, attr, None))
|
||||
for attr in ('current_names', 'deck_names', 'final_names'):
|
||||
val = getattr(builder, attr, None)
|
||||
if isinstance(val, (list, tuple, set)):
|
||||
for n in val:
|
||||
if isinstance(n, str) and n.strip():
|
||||
present.add(n.strip().lower())
|
||||
except Exception:
|
||||
pass
|
||||
return present
|
||||
|
||||
|
||||
def builder_display_map(builder: Any, pool_lower: set[str]) -> Dict[str, str]:
|
||||
"""Map lowercased names in pool_lower to display names using the combined DataFrame, if present."""
|
||||
display_map: Dict[str, str] = {}
|
||||
try:
|
||||
if builder is None or not pool_lower:
|
||||
return display_map
|
||||
df = getattr(builder, "_combined_cards_df", None)
|
||||
if df is not None and not df.empty:
|
||||
sub = df[df["name"].astype(str).str.lower().isin(pool_lower)]
|
||||
for _idx, row in sub.iterrows():
|
||||
display_map[str(row["name"]).strip().lower()] = str(row["name"]).strip()
|
||||
except Exception:
|
||||
display_map = {}
|
||||
return display_map
|
||||
98
code/web/services/combo_utils.py
Normal file
98
code/web/services/combo_utils.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from deck_builder.combos import (
|
||||
detect_combos as _detect_combos,
|
||||
detect_synergies as _detect_synergies,
|
||||
)
|
||||
from tagging.combo_schema import (
|
||||
load_and_validate_combos as _load_combos,
|
||||
load_and_validate_synergies as _load_synergies,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_COMBOS_PATH = "config/card_lists/combos.json"
|
||||
DEFAULT_SYNERGIES_PATH = "config/card_lists/synergies.json"
|
||||
|
||||
|
||||
def detect_all(
|
||||
names: List[str],
|
||||
*,
|
||||
combos_path: str = DEFAULT_COMBOS_PATH,
|
||||
synergies_path: str = DEFAULT_SYNERGIES_PATH,
|
||||
) -> Dict[str, object]:
|
||||
"""Detect combos/synergies for a list of card names and return results with versions.
|
||||
|
||||
Returns a dict with keys: combos, synergies, versions, combos_model, synergies_model.
|
||||
Models may be None if loading fails.
|
||||
"""
|
||||
try:
|
||||
combos_model = _load_combos(combos_path)
|
||||
except Exception:
|
||||
combos_model = None
|
||||
try:
|
||||
synergies_model = _load_synergies(synergies_path)
|
||||
except Exception:
|
||||
synergies_model = None
|
||||
|
||||
try:
|
||||
combos = _detect_combos(names, combos_path=combos_path)
|
||||
except Exception:
|
||||
combos = []
|
||||
try:
|
||||
synergies = _detect_synergies(names, synergies_path=synergies_path)
|
||||
except Exception:
|
||||
synergies = []
|
||||
|
||||
versions = {
|
||||
"combos": getattr(combos_model, "list_version", None) if combos_model else None,
|
||||
"synergies": getattr(synergies_model, "list_version", None) if synergies_model else None,
|
||||
}
|
||||
return {
|
||||
"combos": combos,
|
||||
"synergies": synergies,
|
||||
"versions": versions,
|
||||
"combos_model": combos_model,
|
||||
"synergies_model": synergies_model,
|
||||
}
|
||||
|
||||
|
||||
def _names_from_summary(summary: Dict[str, object]) -> List[str]:
|
||||
"""Extract a best-effort set of card names from a build summary dict."""
|
||||
names_set: set[str] = set()
|
||||
try:
|
||||
tb = (summary or {}).get("type_breakdown", {})
|
||||
cards_by_type = tb.get("cards", {}) if isinstance(tb, dict) else {}
|
||||
for _typ, clist in (cards_by_type.items() if isinstance(cards_by_type, dict) else []):
|
||||
for c in (clist or []):
|
||||
n = str(c.get("name") if isinstance(c, dict) else getattr(c, "name", ""))
|
||||
if n:
|
||||
names_set.add(n)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mc = (summary or {}).get("mana_curve", {})
|
||||
curve_cards = mc.get("cards", {}) if isinstance(mc, dict) else {}
|
||||
for _bucket, clist in (curve_cards.items() if isinstance(curve_cards, dict) else []):
|
||||
for c in (clist or []):
|
||||
n = str(c.get("name") if isinstance(c, dict) else getattr(c, "name", ""))
|
||||
if n:
|
||||
names_set.add(n)
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(names_set)
|
||||
|
||||
|
||||
def detect_for_summary(
|
||||
summary: Dict[str, object] | None,
|
||||
commander_name: str | None = None,
|
||||
*,
|
||||
combos_path: str = DEFAULT_COMBOS_PATH,
|
||||
synergies_path: str = DEFAULT_SYNERGIES_PATH,
|
||||
) -> Dict[str, object]:
|
||||
"""Convenience helper: compute names from summary (+commander) and run detect_all."""
|
||||
names = _names_from_summary(summary or {})
|
||||
if commander_name:
|
||||
names = sorted(set(names) | {str(commander_name)})
|
||||
return detect_all(names, combos_path=combos_path, synergies_path=synergies_path)
|
||||
|
|
@ -484,6 +484,91 @@ def ideal_labels() -> Dict[str, str]:
|
|||
}
|
||||
|
||||
|
||||
def _is_truthy_env(name: str, default: str = '1') -> bool:
|
||||
try:
|
||||
val = os.getenv(name, default)
|
||||
return str(val).strip().lower() in {"1", "true", "yes", "on"}
|
||||
except Exception:
|
||||
return default in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def is_setup_ready() -> bool:
|
||||
"""Fast readiness check: required files present and tagging completed.
|
||||
|
||||
We consider the system ready if csv_files/cards.csv exists and the
|
||||
.tagging_complete.json flag exists. Freshness (mtime) is enforced only
|
||||
during auto-refresh inside _ensure_setup_ready, not here.
|
||||
"""
|
||||
try:
|
||||
cards_path = os.path.join('csv_files', 'cards.csv')
|
||||
flag_path = os.path.join('csv_files', '.tagging_complete.json')
|
||||
return os.path.exists(cards_path) and os.path.exists(flag_path)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_setup_stale() -> bool:
|
||||
"""Return True if cards.csv exists but is older than the auto-refresh threshold.
|
||||
|
||||
This does not imply not-ready; it is a hint for the UI to recommend a refresh.
|
||||
"""
|
||||
try:
|
||||
# Refresh threshold (treat <=0 as "never stale")
|
||||
try:
|
||||
days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7'))
|
||||
except Exception:
|
||||
days = 7
|
||||
if days <= 0:
|
||||
return False
|
||||
refresh_age_seconds = max(0, days) * 24 * 60 * 60
|
||||
|
||||
# If setup is currently running, avoid prompting a refresh loop
|
||||
try:
|
||||
status_path = os.path.join('csv_files', '.setup_status.json')
|
||||
if os.path.exists(status_path):
|
||||
with open(status_path, 'r', encoding='utf-8') as f:
|
||||
st = json.load(f) or {}
|
||||
if bool(st.get('running')):
|
||||
return False
|
||||
# If we recently finished, honor finished_at (or updated) as a freshness signal
|
||||
ts_str = st.get('finished_at') or st.get('updated') or st.get('started_at')
|
||||
if isinstance(ts_str, str) and ts_str.strip():
|
||||
try:
|
||||
ts = _dt.fromisoformat(ts_str.strip())
|
||||
if (time.time() - ts.timestamp()) <= refresh_age_seconds:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If tagging completed recently, treat as fresh regardless of cards.csv mtime
|
||||
try:
|
||||
tag_flag = os.path.join('csv_files', '.tagging_complete.json')
|
||||
if os.path.exists(tag_flag):
|
||||
with open(tag_flag, 'r', encoding='utf-8') as f:
|
||||
tf = json.load(f) or {}
|
||||
tstr = tf.get('tagged_at')
|
||||
if isinstance(tstr, str) and tstr.strip():
|
||||
try:
|
||||
tdt = _dt.fromisoformat(tstr.strip())
|
||||
if (time.time() - tdt.timestamp()) <= refresh_age_seconds:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: compare cards.csv mtime
|
||||
cards_path = os.path.join('csv_files', 'cards.csv')
|
||||
if not os.path.exists(cards_path):
|
||||
return False
|
||||
age_seconds = time.time() - os.path.getmtime(cards_path)
|
||||
return age_seconds > refresh_age_seconds
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_setup_ready(out, force: bool = False) -> None:
|
||||
"""Ensure card CSVs exist and tagging has completed; bootstrap if needed.
|
||||
|
||||
|
|
@ -515,6 +600,13 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
try:
|
||||
cards_path = os.path.join('csv_files', 'cards.csv')
|
||||
flag_path = os.path.join('csv_files', '.tagging_complete.json')
|
||||
auto_setup_enabled = _is_truthy_env('WEB_AUTO_SETUP', '1')
|
||||
# Allow tuning of time-based refresh; default 7 days
|
||||
try:
|
||||
days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7'))
|
||||
refresh_age_seconds = max(0, days) * 24 * 60 * 60
|
||||
except Exception:
|
||||
refresh_age_seconds = 7 * 24 * 60 * 60
|
||||
refresh_needed = bool(force)
|
||||
if force:
|
||||
_write_status({"running": True, "phase": "setup", "message": "Forcing full setup and tagging...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0})
|
||||
|
|
@ -526,7 +618,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
else:
|
||||
try:
|
||||
age_seconds = time.time() - os.path.getmtime(cards_path)
|
||||
if age_seconds > 7 * 24 * 60 * 60 and not force:
|
||||
if age_seconds > refresh_age_seconds and not force:
|
||||
out("cards.csv is older than 7 days. Refreshing data (setup + tagging)...")
|
||||
_write_status({"running": True, "phase": "setup", "message": "Refreshing card database (initial setup)...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0})
|
||||
refresh_needed = True
|
||||
|
|
@ -540,6 +632,10 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
refresh_needed = True
|
||||
|
||||
if refresh_needed:
|
||||
if not auto_setup_enabled and not force:
|
||||
out("Setup/tagging required, but WEB_AUTO_SETUP=0. Please run Setup from the UI.")
|
||||
_write_status({"running": False, "phase": "requires_setup", "message": "Setup required (auto disabled)."})
|
||||
return
|
||||
try:
|
||||
from file_setup.setup import initial_setup # type: ignore
|
||||
# Always run initial_setup when forced or when cards are missing/stale
|
||||
|
|
@ -1082,8 +1178,13 @@ def start_build_ctx(
|
|||
|
||||
# Provide a no-op input function so staged web builds never block on input
|
||||
b = DeckBuilder(output_func=out, input_func=lambda _prompt: "", headless=True)
|
||||
# Ensure setup/tagging present before staged build
|
||||
_ensure_setup_ready(out)
|
||||
# Ensure setup/tagging present before staged build, but respect WEB_AUTO_SETUP
|
||||
if not is_setup_ready():
|
||||
if _is_truthy_env('WEB_AUTO_SETUP', '1'):
|
||||
_ensure_setup_ready(out)
|
||||
else:
|
||||
out("Setup/tagging not ready. Please run Setup first (WEB_AUTO_SETUP=0).")
|
||||
raise RuntimeError("Setup required (WEB_AUTO_SETUP disabled)")
|
||||
# Commander selection
|
||||
df = b.load_commander_data()
|
||||
row = df[df["name"].astype(str) == str(commander)]
|
||||
|
|
|
|||
32
code/web/services/summary_utils.py
Normal file
32
code/web/services/summary_utils.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from deck_builder import builder_constants as bc
|
||||
from .build_utils import owned_set as owned_set_helper
|
||||
from .combo_utils import detect_for_summary as _detect_for_summary
|
||||
|
||||
|
||||
def summary_ctx(
|
||||
*,
|
||||
summary: dict | None,
|
||||
commander: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
include_versions: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a unified context payload for deck summary panels.
|
||||
|
||||
Provides owned_set, game_changers, combos/synergies, and detector versions.
|
||||
"""
|
||||
det = _detect_for_summary(summary, commander_name=commander or "") if summary else {"combos": [], "synergies": [], "versions": {}}
|
||||
combos = det.get("combos", [])
|
||||
synergies = det.get("synergies", [])
|
||||
versions = det.get("versions", {} if include_versions else None)
|
||||
return {
|
||||
"owned_set": owned_set_helper(),
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"combos": combos,
|
||||
"synergies": synergies,
|
||||
"versions": versions,
|
||||
"commander": commander,
|
||||
"tags": tags or [],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue