2025-09-17 13:23:27 -07:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
import time
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
|
|
|
from deck_builder import builder_constants as bc
|
|
|
|
|
from random_util import get_random, generate_seed
|
|
|
|
|
|
|
|
|
|
|
2025-09-25 15:14:15 -07:00
|
|
|
class RandomBuildError(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RandomConstraintsImpossibleError(RandomBuildError):
|
|
|
|
|
def __init__(self, message: str, *, constraints: Optional[Dict[str, Any]] = None, pool_size: Optional[int] = None):
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
self.constraints = constraints or {}
|
|
|
|
|
self.pool_size = int(pool_size or 0)
|
|
|
|
|
|
|
|
|
|
|
2025-09-17 13:23:27 -07:00
|
|
|
@dataclass
|
|
|
|
|
class RandomBuildResult:
|
|
|
|
|
seed: int
|
|
|
|
|
commander: str
|
|
|
|
|
theme: Optional[str]
|
|
|
|
|
constraints: Optional[Dict[str, Any]]
|
2025-09-25 15:14:15 -07:00
|
|
|
# Extended multi-theme support
|
|
|
|
|
primary_theme: Optional[str] = None
|
|
|
|
|
secondary_theme: Optional[str] = None
|
|
|
|
|
tertiary_theme: Optional[str] = None
|
|
|
|
|
resolved_themes: List[str] | None = None # actual AND-combination used for filtering (case-preserved)
|
|
|
|
|
# Diagnostics / fallback metadata
|
|
|
|
|
theme_fallback: bool = False # original single-theme fallback (legacy)
|
|
|
|
|
original_theme: Optional[str] = None
|
|
|
|
|
combo_fallback: bool = False # when we had to drop one or more secondary/tertiary themes
|
|
|
|
|
synergy_fallback: bool = False # when primary itself had no matches and we broadened based on loose overlap
|
|
|
|
|
fallback_reason: Optional[str] = None
|
|
|
|
|
attempts_tried: int = 0
|
|
|
|
|
timeout_hit: bool = False
|
|
|
|
|
retries_exhausted: bool = False
|
2025-09-17 13:23:27 -07:00
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"seed": int(self.seed),
|
|
|
|
|
"commander": self.commander,
|
|
|
|
|
"theme": self.theme,
|
|
|
|
|
"constraints": self.constraints or {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_commanders_df() -> pd.DataFrame:
|
|
|
|
|
"""Load commander CSV using the same path/converters as the builder.
|
|
|
|
|
|
|
|
|
|
Uses bc.COMMANDER_CSV_PATH and bc.COMMANDER_CONVERTERS for consistency.
|
|
|
|
|
"""
|
|
|
|
|
return pd.read_csv(bc.COMMANDER_CSV_PATH, converters=getattr(bc, "COMMANDER_CONVERTERS", None))
|
|
|
|
|
|
|
|
|
|
|
2025-09-25 15:14:15 -07:00
|
|
|
def _normalize_tag(value: Optional[str]) -> Optional[str]:
|
|
|
|
|
if value is None:
|
|
|
|
|
return None
|
|
|
|
|
v = str(value).strip()
|
|
|
|
|
return v if v else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _filter_multi(df: pd.DataFrame, primary: Optional[str], secondary: Optional[str], tertiary: Optional[str]) -> tuple[pd.DataFrame, Dict[str, Any]]:
|
|
|
|
|
"""Return filtered commander dataframe based on ordered fallback strategy.
|
|
|
|
|
|
|
|
|
|
Strategy (P = primary, S = secondary, T = tertiary):
|
|
|
|
|
1. If all P,S,T provided → try P&S&T
|
|
|
|
|
2. If no triple match → try P&S
|
|
|
|
|
3. If no P&S → try P&T (treat tertiary as secondary weight-wise)
|
|
|
|
|
4. If no P+{S|T} → try P alone
|
|
|
|
|
5. If P alone empty → attempt loose synergy fallback (any commander whose themeTags share a word with P)
|
|
|
|
|
6. Else full pool fallback (ultimate guard)
|
|
|
|
|
|
|
|
|
|
Returns (filtered_df, diagnostics_dict)
|
|
|
|
|
diagnostics_dict keys:
|
|
|
|
|
- resolved_themes: list[str]
|
|
|
|
|
- combo_fallback: bool
|
|
|
|
|
- synergy_fallback: bool
|
|
|
|
|
- fallback_reason: str | None
|
|
|
|
|
"""
|
|
|
|
|
diag: Dict[str, Any] = {
|
|
|
|
|
"resolved_themes": None,
|
|
|
|
|
"combo_fallback": False,
|
|
|
|
|
"synergy_fallback": False,
|
|
|
|
|
"fallback_reason": None,
|
|
|
|
|
}
|
|
|
|
|
# Normalize to lowercase for comparison but preserve original for reporting
|
|
|
|
|
p = _normalize_tag(primary)
|
|
|
|
|
s = _normalize_tag(secondary)
|
|
|
|
|
t = _normalize_tag(tertiary)
|
|
|
|
|
# Helper to test AND-combo
|
|
|
|
|
def and_filter(req: List[str]) -> pd.DataFrame:
|
|
|
|
|
if not req:
|
|
|
|
|
return df
|
|
|
|
|
req_l = [r.lower() for r in req]
|
|
|
|
|
try:
|
|
|
|
|
mask = df.get("themeTags").apply(lambda tags: all(any(str(x).strip().lower() == r for x in (tags or [])) for r in req_l))
|
|
|
|
|
return df[mask]
|
|
|
|
|
except Exception:
|
|
|
|
|
return df.iloc[0:0]
|
|
|
|
|
|
|
|
|
|
# 1. Triple
|
|
|
|
|
if p and s and t:
|
|
|
|
|
triple = and_filter([p, s, t])
|
|
|
|
|
if len(triple) > 0:
|
|
|
|
|
diag["resolved_themes"] = [p, s, t]
|
|
|
|
|
return triple, diag
|
|
|
|
|
# 2. P+S
|
|
|
|
|
if p and s:
|
|
|
|
|
ps = and_filter([p, s])
|
|
|
|
|
if len(ps) > 0:
|
|
|
|
|
if t:
|
|
|
|
|
diag["combo_fallback"] = True
|
|
|
|
|
diag["fallback_reason"] = "No commanders matched all three themes; using Primary+Secondary"
|
|
|
|
|
diag["resolved_themes"] = [p, s]
|
|
|
|
|
return ps, diag
|
|
|
|
|
# 3. P+T
|
|
|
|
|
if p and t:
|
|
|
|
|
pt = and_filter([p, t])
|
|
|
|
|
if len(pt) > 0:
|
|
|
|
|
if s:
|
|
|
|
|
diag["combo_fallback"] = True
|
|
|
|
|
diag["fallback_reason"] = "No commanders matched requested combinations; using Primary+Tertiary"
|
|
|
|
|
diag["resolved_themes"] = [p, t]
|
|
|
|
|
return pt, diag
|
|
|
|
|
# 4. P only
|
|
|
|
|
if p:
|
|
|
|
|
p_only = and_filter([p])
|
|
|
|
|
if len(p_only) > 0:
|
|
|
|
|
if s or t:
|
|
|
|
|
diag["combo_fallback"] = True
|
|
|
|
|
diag["fallback_reason"] = "No multi-theme combination matched; using Primary only"
|
|
|
|
|
diag["resolved_themes"] = [p]
|
|
|
|
|
return p_only, diag
|
|
|
|
|
# 5. Synergy fallback based on primary token overlaps
|
|
|
|
|
if p:
|
|
|
|
|
words = [w for w in p.replace('-', ' ').split() if w]
|
|
|
|
|
if words:
|
|
|
|
|
try:
|
|
|
|
|
mask = df.get("themeTags").apply(
|
|
|
|
|
lambda tags: any(
|
|
|
|
|
any(w == str(x).strip().lower() or w in str(x).strip().lower() for w in words)
|
|
|
|
|
for x in (tags or [])
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
synergy_df = df[mask]
|
|
|
|
|
if len(synergy_df) > 0:
|
|
|
|
|
diag["resolved_themes"] = words # approximate overlap tokens
|
|
|
|
|
diag["combo_fallback"] = True
|
|
|
|
|
diag["synergy_fallback"] = True
|
|
|
|
|
diag["fallback_reason"] = "Primary theme had no direct matches; using synergy overlap"
|
|
|
|
|
return synergy_df, diag
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# 6. Full pool fallback
|
|
|
|
|
diag["resolved_themes"] = []
|
|
|
|
|
diag["combo_fallback"] = True
|
|
|
|
|
diag["synergy_fallback"] = True
|
|
|
|
|
diag["fallback_reason"] = "No theme matches found; using full commander pool"
|
|
|
|
|
return df, diag
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _candidate_ok(candidate: str, constraints: Optional[Dict[str, Any]]) -> bool:
|
|
|
|
|
"""Check simple feasibility filters from constraints.
|
|
|
|
|
|
|
|
|
|
Supported keys (lightweight, safe defaults):
|
|
|
|
|
- reject_all: bool -> if True, reject every candidate (useful for retries-exhausted tests)
|
|
|
|
|
- reject_names: list[str] -> reject these specific names
|
|
|
|
|
"""
|
|
|
|
|
if not constraints:
|
|
|
|
|
return True
|
2025-09-17 13:23:27 -07:00
|
|
|
try:
|
2025-09-25 15:14:15 -07:00
|
|
|
if constraints.get("reject_all"):
|
|
|
|
|
return False
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
rej = constraints.get("reject_names")
|
|
|
|
|
if isinstance(rej, (list, tuple)) and any(str(candidate) == str(x) for x in rej):
|
|
|
|
|
return False
|
2025-09-17 13:23:27 -07:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-25 15:14:15 -07:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _check_constraints(candidate_count: int, constraints: Optional[Dict[str, Any]]) -> None:
|
|
|
|
|
if not constraints:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
req_min = constraints.get("require_min_candidates") # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
req_min = None
|
|
|
|
|
if req_min is None:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
req_min_int = int(req_min)
|
|
|
|
|
except Exception:
|
|
|
|
|
req_min_int = None
|
|
|
|
|
if req_min_int is not None and candidate_count < req_min_int:
|
|
|
|
|
raise RandomConstraintsImpossibleError(
|
|
|
|
|
f"Not enough candidates to satisfy constraints (have {candidate_count}, require >= {req_min_int})",
|
|
|
|
|
constraints=constraints,
|
|
|
|
|
pool_size=candidate_count,
|
|
|
|
|
)
|
2025-09-17 13:23:27 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_random_deck(
|
|
|
|
|
theme: Optional[str] = None,
|
|
|
|
|
constraints: Optional[Dict[str, Any]] = None,
|
|
|
|
|
seed: Optional[int | str] = None,
|
|
|
|
|
attempts: int = 5,
|
|
|
|
|
timeout_s: float = 5.0,
|
2025-09-25 15:14:15 -07:00
|
|
|
# New multi-theme inputs (theme retained for backward compatibility as primary)
|
|
|
|
|
primary_theme: Optional[str] = None,
|
|
|
|
|
secondary_theme: Optional[str] = None,
|
|
|
|
|
tertiary_theme: Optional[str] = None,
|
2025-09-17 13:23:27 -07:00
|
|
|
) -> RandomBuildResult:
|
|
|
|
|
"""Thin wrapper for random selection of a commander, deterministic when seeded.
|
|
|
|
|
|
|
|
|
|
Contract (initial/minimal):
|
|
|
|
|
- Inputs: optional theme filter, optional constraints dict, seed for determinism,
|
|
|
|
|
attempts (max reroll attempts), timeout_s (wall clock cap).
|
|
|
|
|
- Output: RandomBuildResult with chosen commander and the resolved seed.
|
|
|
|
|
|
|
|
|
|
Notes:
|
|
|
|
|
- This does NOT run the full deck builder yet; it focuses on picking a commander
|
|
|
|
|
deterministically for tests and plumbing. Full pipeline can be layered later.
|
|
|
|
|
- Determinism: when `seed` is provided, selection is stable across runs.
|
|
|
|
|
- When `seed` is None, a new high-entropy seed is generated and returned.
|
|
|
|
|
"""
|
|
|
|
|
# Resolve seed and RNG
|
|
|
|
|
resolved_seed = int(seed) if isinstance(seed, int) or (isinstance(seed, str) and str(seed).isdigit()) else None
|
|
|
|
|
if resolved_seed is None:
|
|
|
|
|
resolved_seed = generate_seed()
|
|
|
|
|
rng = get_random(resolved_seed)
|
|
|
|
|
|
|
|
|
|
# Bounds sanitation
|
|
|
|
|
attempts = max(1, int(attempts or 1))
|
|
|
|
|
try:
|
|
|
|
|
timeout_s = float(timeout_s)
|
|
|
|
|
except Exception:
|
|
|
|
|
timeout_s = 5.0
|
|
|
|
|
timeout_s = max(0.1, timeout_s)
|
|
|
|
|
|
2025-09-25 15:14:15 -07:00
|
|
|
# Resolve multi-theme inputs
|
|
|
|
|
if primary_theme is None:
|
|
|
|
|
primary_theme = theme # legacy single theme becomes primary
|
2025-09-17 13:23:27 -07:00
|
|
|
df_all = _load_commanders_df()
|
2025-09-25 15:14:15 -07:00
|
|
|
df, multi_diag = _filter_multi(df_all, primary_theme, secondary_theme, tertiary_theme)
|
|
|
|
|
used_fallback = False
|
|
|
|
|
original_theme = None
|
|
|
|
|
if multi_diag.get("combo_fallback") or multi_diag.get("synergy_fallback"):
|
|
|
|
|
# For legacy fields
|
|
|
|
|
used_fallback = bool(multi_diag.get("combo_fallback"))
|
|
|
|
|
original_theme = primary_theme if primary_theme else None
|
2025-09-17 13:23:27 -07:00
|
|
|
# Stable ordering then seeded selection for deterministic behavior
|
|
|
|
|
names: List[str] = sorted(df["name"].astype(str).tolist()) if not df.empty else []
|
|
|
|
|
if not names:
|
|
|
|
|
# Fall back to entire pool by name if theme produced nothing
|
|
|
|
|
names = sorted(df_all["name"].astype(str).tolist())
|
|
|
|
|
if not names:
|
|
|
|
|
# Absolute fallback for pathological cases
|
|
|
|
|
names = ["Unknown Commander"]
|
|
|
|
|
|
2025-09-25 15:14:15 -07:00
|
|
|
# Constraint feasibility check (based on candidate count)
|
|
|
|
|
_check_constraints(len(names), constraints)
|
|
|
|
|
|
2025-09-17 13:23:27 -07:00
|
|
|
# Simple attempt/timeout loop (placeholder for future constraints checks)
|
|
|
|
|
start = time.time()
|
|
|
|
|
pick = None
|
2025-09-25 15:14:15 -07:00
|
|
|
attempts_tried = 0
|
|
|
|
|
timeout_hit = False
|
|
|
|
|
for i in range(attempts):
|
2025-09-17 13:23:27 -07:00
|
|
|
if (time.time() - start) > timeout_s:
|
2025-09-25 15:14:15 -07:00
|
|
|
timeout_hit = True
|
2025-09-17 13:23:27 -07:00
|
|
|
break
|
2025-09-25 15:14:15 -07:00
|
|
|
attempts_tried = i + 1
|
2025-09-17 13:23:27 -07:00
|
|
|
idx = rng.randrange(0, len(names))
|
|
|
|
|
candidate = names[idx]
|
2025-09-25 15:14:15 -07:00
|
|
|
# Accept only if candidate passes simple feasibility filters
|
|
|
|
|
if _candidate_ok(candidate, constraints):
|
|
|
|
|
pick = candidate
|
|
|
|
|
break
|
|
|
|
|
# else continue and try another candidate until attempts/timeout
|
|
|
|
|
retries_exhausted = (pick is None) and (not timeout_hit) and (attempts_tried >= attempts)
|
2025-09-17 13:23:27 -07:00
|
|
|
if pick is None:
|
|
|
|
|
# Timeout/attempts exhausted; choose deterministically based on seed modulo
|
|
|
|
|
pick = names[resolved_seed % len(names)]
|
|
|
|
|
|
2025-09-25 15:14:15 -07:00
|
|
|
return RandomBuildResult(
|
|
|
|
|
seed=int(resolved_seed),
|
|
|
|
|
commander=pick,
|
|
|
|
|
theme=primary_theme, # preserve prior contract
|
|
|
|
|
constraints=constraints or {},
|
|
|
|
|
primary_theme=primary_theme,
|
|
|
|
|
secondary_theme=secondary_theme,
|
|
|
|
|
tertiary_theme=tertiary_theme,
|
|
|
|
|
resolved_themes=list(multi_diag.get("resolved_themes") or []),
|
|
|
|
|
combo_fallback=bool(multi_diag.get("combo_fallback")),
|
|
|
|
|
synergy_fallback=bool(multi_diag.get("synergy_fallback")),
|
|
|
|
|
fallback_reason=multi_diag.get("fallback_reason"),
|
|
|
|
|
theme_fallback=bool(used_fallback),
|
|
|
|
|
original_theme=original_theme,
|
|
|
|
|
attempts_tried=int(attempts_tried or (1 if pick else 0)),
|
|
|
|
|
timeout_hit=bool(timeout_hit),
|
|
|
|
|
retries_exhausted=bool(retries_exhausted),
|
|
|
|
|
)
|
2025-09-17 13:23:27 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
|
|
"RandomBuildResult",
|
|
|
|
|
"build_random_deck",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Full-build wrapper for deterministic end-to-end builds
|
|
|
|
|
@dataclass
|
|
|
|
|
class RandomFullBuildResult(RandomBuildResult):
|
|
|
|
|
decklist: List[Dict[str, Any]] | None = None
|
|
|
|
|
diagnostics: Dict[str, Any] | None = None
|
2025-09-25 15:14:15 -07:00
|
|
|
summary: Dict[str, Any] | None = None
|
|
|
|
|
csv_path: str | None = None
|
|
|
|
|
txt_path: str | None = None
|
|
|
|
|
compliance: Dict[str, Any] | None = None
|
2025-09-17 13:23:27 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_random_full_deck(
|
|
|
|
|
theme: Optional[str] = None,
|
|
|
|
|
constraints: Optional[Dict[str, Any]] = None,
|
|
|
|
|
seed: Optional[int | str] = None,
|
|
|
|
|
attempts: int = 5,
|
|
|
|
|
timeout_s: float = 5.0,
|
|
|
|
|
) -> RandomFullBuildResult:
|
|
|
|
|
"""Select a commander deterministically, then run a full deck build via DeckBuilder.
|
|
|
|
|
|
|
|
|
|
Returns a compact result including the seed, commander, and a summarized decklist.
|
|
|
|
|
"""
|
2025-09-25 15:14:15 -07:00
|
|
|
t0 = time.time()
|
2025-09-17 13:23:27 -07:00
|
|
|
base = build_random_deck(theme=theme, constraints=constraints, seed=seed, attempts=attempts, timeout_s=timeout_s)
|
|
|
|
|
|
|
|
|
|
# Run the full headless build with the chosen commander and the same seed
|
|
|
|
|
try:
|
|
|
|
|
from headless_runner import run as _run # type: ignore
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return RandomFullBuildResult(
|
|
|
|
|
seed=base.seed,
|
|
|
|
|
commander=base.commander,
|
|
|
|
|
theme=base.theme,
|
|
|
|
|
constraints=base.constraints or {},
|
|
|
|
|
decklist=None,
|
|
|
|
|
diagnostics={"error": f"headless runner unavailable: {e}"},
|
|
|
|
|
)
|
|
|
|
|
|
2025-09-25 15:14:15 -07:00
|
|
|
# Run the full builder once; reuse object for summary + deck extraction
|
|
|
|
|
# Default behavior: suppress the initial internal export so Random build controls artifacts.
|
|
|
|
|
# (If user explicitly sets RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT=0 we respect that.)
|
|
|
|
|
try:
|
|
|
|
|
import os as _os
|
|
|
|
|
if _os.getenv('RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT') is None:
|
|
|
|
|
_os.environ['RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT'] = '1'
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-17 13:23:27 -07:00
|
|
|
builder = _run(command_name=base.commander, seed=base.seed)
|
|
|
|
|
|
2025-09-25 15:14:15 -07:00
|
|
|
# Build summary (may fail gracefully)
|
|
|
|
|
summary: Dict[str, Any] | None = None
|
|
|
|
|
try:
|
|
|
|
|
if hasattr(builder, 'build_deck_summary'):
|
|
|
|
|
summary = builder.build_deck_summary() # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
summary = None
|
|
|
|
|
|
|
|
|
|
# Attempt to reuse existing export performed inside builder (headless run already exported)
|
|
|
|
|
csv_path: str | None = None
|
|
|
|
|
txt_path: str | None = None
|
|
|
|
|
compliance: Dict[str, Any] | None = None
|
|
|
|
|
try:
|
|
|
|
|
import os as _os
|
|
|
|
|
import json as _json
|
|
|
|
|
csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined]
|
|
|
|
|
txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined]
|
|
|
|
|
if csv_path and isinstance(csv_path, str):
|
|
|
|
|
base_path, _ = _os.path.splitext(csv_path)
|
|
|
|
|
# If txt missing but expected, look for sibling
|
|
|
|
|
if (not txt_path or not _os.path.isfile(str(txt_path))) and _os.path.isfile(base_path + '.txt'):
|
|
|
|
|
txt_path = base_path + '.txt'
|
|
|
|
|
# Load existing compliance if present
|
|
|
|
|
comp_path = base_path + '_compliance.json'
|
|
|
|
|
if _os.path.isfile(comp_path):
|
|
|
|
|
try:
|
|
|
|
|
with open(comp_path, 'r', encoding='utf-8') as _cf:
|
|
|
|
|
compliance = _json.load(_cf)
|
|
|
|
|
except Exception:
|
|
|
|
|
compliance = None
|
|
|
|
|
else:
|
|
|
|
|
# Compute compliance if not already saved
|
|
|
|
|
try:
|
|
|
|
|
if hasattr(builder, 'compute_and_print_compliance'):
|
|
|
|
|
compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
compliance = None
|
|
|
|
|
# Write summary sidecar if missing
|
|
|
|
|
if summary:
|
|
|
|
|
sidecar = base_path + '.summary.json'
|
|
|
|
|
if not _os.path.isfile(sidecar):
|
|
|
|
|
meta = {
|
|
|
|
|
"commander": getattr(builder, 'commander_name', '') or getattr(builder, 'commander', ''),
|
|
|
|
|
"tags": list(getattr(builder, 'selected_tags', []) or []) or [t for t in [getattr(builder, 'primary_tag', None), getattr(builder, 'secondary_tag', None), getattr(builder, 'tertiary_tag', None)] if t],
|
|
|
|
|
"bracket_level": getattr(builder, 'bracket_level', None),
|
|
|
|
|
"csv": csv_path,
|
|
|
|
|
"txt": txt_path,
|
|
|
|
|
"random_seed": base.seed,
|
|
|
|
|
"random_theme": base.theme,
|
|
|
|
|
"random_constraints": base.constraints or {},
|
|
|
|
|
}
|
|
|
|
|
try:
|
|
|
|
|
custom_base = getattr(builder, 'custom_export_base', None)
|
|
|
|
|
except Exception:
|
|
|
|
|
custom_base = None
|
|
|
|
|
if isinstance(custom_base, str) and custom_base.strip():
|
|
|
|
|
meta["name"] = custom_base.strip()
|
|
|
|
|
try:
|
|
|
|
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
|
|
|
|
_json.dump({"meta": meta, "summary": summary}, f, ensure_ascii=False, indent=2)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
# Fallback: export now (rare path if headless build skipped export)
|
|
|
|
|
if hasattr(builder, 'export_decklist_csv'):
|
|
|
|
|
try:
|
|
|
|
|
# Before exporting, attempt to find an existing same-day base file (non-suffixed) to avoid duplicate export
|
|
|
|
|
existing_base: str | None = None
|
|
|
|
|
try:
|
|
|
|
|
import glob as _glob
|
|
|
|
|
today = time.strftime('%Y%m%d')
|
|
|
|
|
# Commander slug approximation: remove non alnum underscores
|
|
|
|
|
import re as _re
|
|
|
|
|
cmdr = (getattr(builder, 'commander_name', '') or getattr(builder, 'commander', '') or '')
|
|
|
|
|
slug = _re.sub(r'[^A-Za-z0-9_]+', '', cmdr) or 'deck'
|
|
|
|
|
pattern = f"deck_files/{slug}_*_{today}.csv"
|
|
|
|
|
for path in sorted(_glob.glob(pattern)):
|
|
|
|
|
base_name = _os.path.basename(path)
|
|
|
|
|
if '_1.csv' not in base_name: # prefer original
|
|
|
|
|
existing_base = path
|
|
|
|
|
break
|
|
|
|
|
except Exception:
|
|
|
|
|
existing_base = None
|
|
|
|
|
if existing_base and _os.path.isfile(existing_base):
|
|
|
|
|
csv_path = existing_base
|
|
|
|
|
base_path, _ = _os.path.splitext(csv_path)
|
|
|
|
|
else:
|
|
|
|
|
tmp_csv = builder.export_decklist_csv() # type: ignore[attr-defined]
|
|
|
|
|
stem_base, ext = _os.path.splitext(tmp_csv)
|
|
|
|
|
if stem_base.endswith('_1'):
|
|
|
|
|
original = stem_base[:-2] + ext
|
|
|
|
|
if _os.path.isfile(original):
|
|
|
|
|
csv_path = original
|
|
|
|
|
else:
|
|
|
|
|
csv_path = tmp_csv
|
|
|
|
|
else:
|
|
|
|
|
csv_path = tmp_csv
|
|
|
|
|
base_path, _ = _os.path.splitext(csv_path)
|
|
|
|
|
if hasattr(builder, 'export_decklist_text'):
|
|
|
|
|
target_txt = base_path + '.txt'
|
|
|
|
|
if _os.path.isfile(target_txt):
|
|
|
|
|
txt_path = target_txt
|
|
|
|
|
else:
|
|
|
|
|
tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt') # type: ignore[attr-defined]
|
|
|
|
|
if tmp_txt.endswith('_1.txt') and _os.path.isfile(target_txt):
|
|
|
|
|
txt_path = target_txt
|
|
|
|
|
else:
|
|
|
|
|
txt_path = tmp_txt
|
|
|
|
|
if hasattr(builder, 'compute_and_print_compliance'):
|
|
|
|
|
compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined]
|
|
|
|
|
if summary:
|
|
|
|
|
sidecar = base_path + '.summary.json'
|
|
|
|
|
if not _os.path.isfile(sidecar):
|
|
|
|
|
meta = {
|
|
|
|
|
"commander": getattr(builder, 'commander_name', '') or getattr(builder, 'commander', ''),
|
|
|
|
|
"tags": list(getattr(builder, 'selected_tags', []) or []) or [t for t in [getattr(builder, 'primary_tag', None), getattr(builder, 'secondary_tag', None), getattr(builder, 'tertiary_tag', None)] if t],
|
|
|
|
|
"bracket_level": getattr(builder, 'bracket_level', None),
|
|
|
|
|
"csv": csv_path,
|
|
|
|
|
"txt": txt_path,
|
|
|
|
|
"random_seed": base.seed,
|
|
|
|
|
"random_theme": base.theme,
|
|
|
|
|
"random_constraints": base.constraints or {},
|
|
|
|
|
}
|
|
|
|
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
|
|
|
|
_json.dump({"meta": meta, "summary": summary}, f, ensure_ascii=False, indent=2)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Extract a simple decklist (name/count)
|
2025-09-17 13:23:27 -07:00
|
|
|
deck_items: List[Dict[str, Any]] = []
|
|
|
|
|
try:
|
|
|
|
|
lib = getattr(builder, 'card_library', {}) or {}
|
|
|
|
|
for name, info in lib.items():
|
|
|
|
|
try:
|
|
|
|
|
cnt = int(info.get('Count', 1)) if isinstance(info, dict) else 1
|
|
|
|
|
except Exception:
|
|
|
|
|
cnt = 1
|
|
|
|
|
deck_items.append({"name": str(name), "count": cnt})
|
|
|
|
|
deck_items.sort(key=lambda x: (str(x.get("name", "").lower()), int(x.get("count", 0))))
|
|
|
|
|
except Exception:
|
|
|
|
|
deck_items = []
|
|
|
|
|
|
2025-09-25 15:14:15 -07:00
|
|
|
elapsed_ms = int((time.time() - t0) * 1000)
|
|
|
|
|
diags: Dict[str, Any] = {
|
|
|
|
|
"attempts": int(getattr(base, "attempts_tried", 1) or 1),
|
|
|
|
|
"timeout_s": float(timeout_s),
|
|
|
|
|
"elapsed_ms": elapsed_ms,
|
|
|
|
|
"fallback": bool(base.theme_fallback),
|
|
|
|
|
"timeout_hit": bool(getattr(base, "timeout_hit", False)),
|
|
|
|
|
"retries_exhausted": bool(getattr(base, "retries_exhausted", False)),
|
|
|
|
|
}
|
2025-09-17 13:23:27 -07:00
|
|
|
return RandomFullBuildResult(
|
|
|
|
|
seed=base.seed,
|
|
|
|
|
commander=base.commander,
|
|
|
|
|
theme=base.theme,
|
|
|
|
|
constraints=base.constraints or {},
|
|
|
|
|
decklist=deck_items,
|
|
|
|
|
diagnostics=diags,
|
2025-09-25 15:14:15 -07:00
|
|
|
summary=summary,
|
|
|
|
|
csv_path=csv_path,
|
|
|
|
|
txt_path=txt_path,
|
|
|
|
|
compliance=compliance,
|
2025-09-17 13:23:27 -07:00
|
|
|
)
|
|
|
|
|
|