chore: prep 2.3.1 docs and note Hero creature handling
Some checks are pending
CI / build (push) Waiting to run
Preview Performance Regression Gate / preview-perf (push) Waiting to run

This commit is contained in:
matt 2025-09-29 23:00:57 -07:00
parent 2c4eb4ba23
commit 4f7d39acba
11 changed files with 1036 additions and 226 deletions

View file

@ -13,7 +13,7 @@
# HOST=0.0.0.0 # Uvicorn bind host (only when APP_MODE=web).
# PORT=8080 # Uvicorn port.
# WORKERS=1 # Uvicorn worker count.
APP_VERSION=v2.2.10 # Matches dockerhub compose.
APP_VERSION=v2.3.1 # Matches dockerhub compose.
############################
# Theming
@ -50,6 +50,19 @@ WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics f
# RANDOM_UI=1 # Show Surprise/Reroll/Share controls in UI
# RANDOM_MAX_ATTEMPTS=5 # Cap retry attempts for constrained random builds
# RANDOM_TIMEOUT_MS=5000 # Per-attempt timeout (ms)
# HEADLESS_RANDOM_MODE=1 # Force headless runner to invoke random flow instead of scripted build
# RANDOM_THEME=Treasure # Legacy single-theme alias (maps to primary theme if others unset)
# RANDOM_PRIMARY_THEME=Treasure # Primary theme slug (case-insensitive)
# RANDOM_SECONDARY_THEME=Artifacts # Secondary theme slug
# RANDOM_TERTIARY_THEME=Tokens # Tertiary theme slug
# RANDOM_AUTO_FILL=1 # Auto-fill missing secondary/tertiary slots
# RANDOM_AUTO_FILL_SECONDARY=1 # Explicit secondary auto-fill override (fallback from RANDOM_AUTO_FILL)
# RANDOM_AUTO_FILL_TERTIARY=1 # Explicit tertiary auto-fill override (fallback from RANDOM_AUTO_FILL)
# RANDOM_STRICT_THEME_MATCH=0 # Require strict commander theme matches
# RANDOM_CONSTRAINTS= # Inline JSON or path to JSON constraints for random selection
# RANDOM_CONSTRAINTS_PATH= # Alternate path-based constraints override (takes precedence if set)
# RANDOM_SEED= # Optional deterministic seed (int or string)
# RANDOM_OUTPUT_JSON=deck_files/random_build.json # Where to write random build metadata payload
############################
# Automation & Performance (Web)

View file

@ -14,10 +14,22 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Added
- _No changes yet._
### Changed
- _No changes yet._
### Fixed
- _No changes yet._
## [2.3.1] - 2025-09-29
### Added
- Headless runner parity: added `--random-mode` and accompanying `--random-*` flags to mirror the web Surprise/Reroll builder (multi-theme inputs, auto-fill overrides, deterministic seeds, constraints, and optional JSON payload export).
- Tests: added `test_headless_skips_owned_prompt_when_files_present` to guard the headless runner against regressions when owned card lists are present.
- Included the tiny `csv_files/testdata` fixture set so CI fast determinism tests have consistent sample data.
### Changed
- Configuration docs: docker compose manifests, `.env.example`, and README now enumerate all supported random-mode environment variables with sensible defaults and refreshed flag documentation for the headless runner.
- Owned Cards library tiles now use larger thumbnails and wider columns, and virtualization only activates when more than 800 cards are present to keep scrolling smooth.
- Theme catalog schema now accepts optional `id` values on entries so refreshed catalogs validate cleanly.
- CI installs `httpx` with the rest of the web stack and runs pytest via `python -m pytest` so FastAPI tests resolve the local `code` package correctly.
@ -40,6 +52,7 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Duplicate overlap highlighting on desktop hover has been removed; theme pills now render once without stray bullets even when multiple overlaps are present.
- Headless runner no longer loops on the power bracket prompt when owned card files exist; scripted responses now auto-select defaults with optional `HEADLESS_USE_OWNED_ONLY` / `HEADLESS_OWNED_SELECTION` overrides for automation flows.
- Regenerated `logs/perf/theme_preview_warm_baseline.json` to repair preview performance CI regressions caused by a malformed baseline file and verified the regression gate passes with the refreshed data.
- File setup now keeps cards with the Hero creature type; previously they were filtered out alongside the non-Commander-legal Hero card type.
## [2.3.0] - 2025-09-26
### Added

BIN
README.md

Binary file not shown.

View file

@ -6,6 +6,7 @@
- Fast-path catalog validation now tolerates empty synergy lists while still flagging missing fields or non-string entries.
- Committed deterministic CSV fixtures under `csv_files/testdata` so CI random-mode checks have a stable dataset.
- Owned Cards library tiles are larger, virtualization only kicks in for very large libraries, and popovers no longer show empty role pills.
- File setup now keeps Hero creature type cards instead of filtering them with the non-Commander-legal Hero card type.
- Random Mode fallback warning remains hidden when no theme filters are provided, keeping Surprise Me runs noise-free.
- Hover previews regained the double-faced card flip button with state synced to the main tile, highlight only the themes that triggered inclusion, and present a mobile-friendly tap layout with centered positioning plus a close control.
- Deck summary text view exposes inline flip toggles for double-faced cards so counts and face switching stay in sync.
@ -16,6 +17,7 @@
- Added opt-in telemetry counters, reroll throttling safeguards, and structured diagnostics exports.
- Expanded tooling, documentation, and QA coverage for theme governance, performance profiling, and seed history management.
- Hardened the headless runner against owned-card prompt loops with optional automation overrides and regression coverage.
- Headless runner random mode now mirrors the web UI configuration surface with CLI flag documentation, dry-run summaries, and JSON export parity.
## Highlights
### Multi-theme random builds
@ -43,6 +45,7 @@
- Random theme exclusion catalog with reporting script and documentation, alongside a multi-theme performance profiler and regression guard.
- Taxonomy snapshot tooling, splash penalty analytics, and governance documentation updated for strict alias and example enforcement.
- README, CHANGELOG, and release notes refreshed to cover the random modes feature set.
- Headless runner documentation now covers random mode CLI flags, env precedence, and parity with the web builder.
### Observability & QA
- Diagnostics badge polish, recent/favorite seeds panel, seed history API, and structured logging for random builds.
@ -55,6 +58,7 @@
- GitHub Actions now includes `httpx` in the default dependency install and executes pytest via `python -m` so FastAPI TestClient suites run without import errors.
- Fast path validator treats empty synergy arrays as acceptable and only warns on missing or malformed data, reducing noise during automated catalog generation.
- Tracked the tiny `csv_files/testdata` dataset in Git to guarantee fast determinism tests run against a consistent fixture set.
- File setup retains cards tagged with the Hero creature type while continuing to skip the non-Commander-legal Hero card type.
## Detailed changes
### Added
@ -92,6 +96,7 @@
- Cache bust hooks now clear filter/preview caches on catalog refresh or tagging completion; metrics expose `preview_last_bust_at` and warm cache stats.
- Theme normalization standardizes terms (ETB → Enter the Battlefield, Pillow Fort → Pillowfort, etc.), with synergy output capped at five entries (curated > enforced > inferred ordering).
- README, CHANGELOG, and governance docs updated to reflect new workflows, taxonomy snapshots, and telemetry controls.
- Headless runner random mode uses shared configuration resolution, emits summary/payload artifacts, and exposes CLI flags matching the web UI.
- Theme catalog schema now allows optional `id` fields on entries so regenerated catalogs validate cleanly.
### Deprecated
@ -106,10 +111,12 @@
- Corrected commander eligibility rules to restrict non-creature legendary permanents and honor “can be your commander” text.
- Refreshed `logs/perf/theme_preview_warm_baseline.json` to fix preview performance CI failures stemming from malformed baseline data.
- Prevented the headless runner from looping on bracket selection when owned card files exist by scripting prompt responses and exposing `HEADLESS_USE_OWNED_ONLY` / `HEADLESS_OWNED_SELECTION` overrides.
- Fixed file setup filtering so Hero creature type cards remain available even though the Hero card type stays disallowed for Commander.
## Upgrade notes
- Enable multi-theme random builds via existing Random Mode flags; strict matching persists automatically across UI, API, permalink, and export contexts.
- Opt into telemetry by setting `RANDOM_TELEMETRY=1`; reroll throttle defaults are active but can be tuned through environment overrides.
- Run headless random builds with `HEADLESS_RANDOM_MODE=1` or the new CLI `--random-*` flags to mirror Surprise Me behavior, optionally persisting outputs via `--random-output-json`.
- Refresh performance baselines with `code/scripts/check_random_theme_perf.py --update-baseline` when catalog changes materially affect timings.
## Testing

View file

@ -33,7 +33,15 @@ BANNED_CARDS: List[str] = [
'Trade Secrets', 'Upheaval', "Yawgmoth's Bargain",
# Problematic / culturally sensitive or banned in other formats
'Invoke Prejudice', 'Cleanse', 'Stone-Throwing Devils', 'Pradesh Gypsies',
'Jihad', 'Imprison', 'Crusade'
'Jihad', 'Imprison', 'Crusade',
# Cards of the Hero type (non creature)
"The Protector", "The Hunter", "The Savant", "The Explorer",
"The Philosopher", "The Harvester", "The Tyrant", "The Vanquisher",
"The Avenger", "The Slayer", "The Warmonger", "The Destined",
"The Warrior", "The General", "The Provider", "The Champion",
# Hero Equipment
"Spear of the General", "Lash of the Tyrant", "Bow of the Hunter",
"Cloak of the Philosopher", "Axe of the Warmonger"
]
# Constants for setup and CSV processing
@ -60,7 +68,6 @@ CARD_TYPES_TO_EXCLUDE: List[str] = [
'Phenomenon',
'Stickers',
'Attraction',
'Hero',
'Contraption'
]

View file

@ -3,7 +3,8 @@ from __future__ import annotations
import argparse
import json
import os
from typing import Any, Dict, List, Optional
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from deck_builder.builder import DeckBuilder
from deck_builder import builder_constants as bc
@ -65,6 +66,25 @@ def _headless_list_owned_files() -> List[str]:
return []
return sorted(entries)
@dataclass
class RandomRunConfig:
"""Runtime options for the headless random build flow."""
legacy_theme: Optional[str] = None
primary_theme: Optional[str] = None
secondary_theme: Optional[str] = None
tertiary_theme: Optional[str] = None
auto_fill_missing: bool = False
auto_fill_secondary: Optional[bool] = None
auto_fill_tertiary: Optional[bool] = None
strict_theme_match: bool = False
attempts: int = 5
timeout_ms: int = 5000
seed: Optional[int | str] = None
constraints: Dict[str, Any] = field(default_factory=dict)
output_json: Optional[str] = None
def run(
command_name: str = "",
add_creatures: bool = True,
@ -446,6 +466,574 @@ def _load_json_config(path: Optional[str]) -> Dict[str, Any]:
raise
def _load_constraints_spec(spec: Any) -> Dict[str, Any]:
"""Load random constraints from a dict, JSON string, or file path."""
if not spec:
return {}
if isinstance(spec, dict):
return dict(spec)
try:
text = str(spec).strip()
except Exception:
return {}
if not text:
return {}
# Treat existing file paths as JSON documents
if os.path.isfile(text):
try:
with open(text, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
if isinstance(loaded, dict):
return loaded
except Exception as exc:
print(f"Warning: failed to load constraints from '{text}': {exc}")
return {}
# Fallback: parse inline JSON
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed
except Exception as exc:
print(f"Warning: failed to parse inline constraints '{text}': {exc}")
return {}
def _try_convert_seed(value: Any) -> Optional[int | str]:
if value is None:
return None
if isinstance(value, int):
return value
try:
text = str(value).strip()
except Exception:
return None
if not text:
return None
try:
return int(text)
except ValueError:
return text
def _resolve_pathish_target(target: str, seed: Any) -> str:
"""Return a concrete file path for an output target, creating directories as needed."""
if not target:
raise ValueError("Empty output path provided")
normalized = target.strip()
if not normalized:
raise ValueError("Blank output path provided")
looks_dir = normalized.endswith(("/", "\\"))
if os.path.isdir(normalized) or looks_dir:
base_dir = normalized.rstrip("/\\") or "."
os.makedirs(base_dir, exist_ok=True)
seed_suffix = str(seed) if seed is not None else "latest"
filename = f"random_build_{seed_suffix}.json"
return os.path.join(base_dir, filename)
base_dir = os.path.dirname(normalized)
if base_dir:
os.makedirs(base_dir, exist_ok=True)
return normalized
def _resolve_random_bool(
cli_value: Optional[bool],
env_name: str,
random_section: Dict[str, Any],
json_key: str,
default: Optional[bool],
) -> Optional[bool]:
if cli_value is not None:
return bool(cli_value)
env_val = os.getenv(env_name)
result = _parse_bool(env_val) if env_val is not None else None
if result is not None:
return result
if json_key in random_section:
result = _parse_bool(random_section.get(json_key))
if result is not None:
return result
return default
def _resolve_random_str(
cli_value: Optional[str],
env_name: str,
random_section: Dict[str, Any],
json_key: str,
default: Optional[str] = None,
) -> Optional[str]:
candidates: Tuple[Any, ...] = (
cli_value,
os.getenv(env_name),
random_section.get(json_key),
default,
)
for candidate in candidates:
if candidate is None:
continue
try:
text = str(candidate).strip()
except Exception:
continue
if text:
return text
return None
def _resolve_random_int(
cli_value: Optional[int],
env_name: str,
random_section: Dict[str, Any],
json_key: str,
default: int,
) -> int:
if cli_value is not None:
try:
return int(cli_value)
except Exception:
pass
env_val = os.getenv(env_name)
if env_val is not None and str(env_val).strip() != "":
try:
return int(float(str(env_val).strip()))
except Exception:
pass
if json_key in random_section:
value = random_section.get(json_key)
try:
if isinstance(value, str):
value = value.strip()
if value:
return int(float(value))
elif value is not None:
return int(value)
except Exception:
pass
return default
def _resolve_random_seed(cli_value: Optional[str], random_section: Dict[str, Any]) -> Optional[int | str]:
seed = _try_convert_seed(cli_value)
if seed is not None:
return seed
seed = _try_convert_seed(os.getenv("RANDOM_SEED"))
if seed is not None:
return seed
return _try_convert_seed(random_section.get("seed"))
def _extract_random_section(json_cfg: Dict[str, Any]) -> Dict[str, Any]:
section = json_cfg.get("random")
if isinstance(section, dict):
return dict(section)
alt = json_cfg.get("random_config")
if isinstance(alt, dict):
return dict(alt)
return {}
def _should_run_random_mode(args: argparse.Namespace, json_cfg: Dict[str, Any], random_section: Dict[str, Any]) -> bool:
if getattr(args, "random_mode", False):
return True
if _parse_bool(os.getenv("HEADLESS_RANDOM_MODE")):
return True
if (os.getenv("DECK_MODE") or "").strip().lower() == "random":
return True
if _parse_bool(json_cfg.get("random_mode")):
return True
if _parse_bool(random_section.get("enabled")):
return True
# Detect CLI or env hints that imply random mode even without explicit flag
cli_indicators = (
getattr(args, "random_theme", None),
getattr(args, "random_primary_theme", None),
getattr(args, "random_secondary_theme", None),
getattr(args, "random_tertiary_theme", None),
getattr(args, "random_seed", None),
getattr(args, "random_auto_fill", None),
getattr(args, "random_auto_fill_secondary", None),
getattr(args, "random_auto_fill_tertiary", None),
getattr(args, "random_strict_theme_match", None),
getattr(args, "random_attempts", None),
getattr(args, "random_timeout_ms", None),
getattr(args, "random_constraints", None),
getattr(args, "random_output_json", None),
)
if any(value is not None for value in cli_indicators):
return True
for env_name in (
"RANDOM_THEME",
"RANDOM_PRIMARY_THEME",
"RANDOM_SECONDARY_THEME",
"RANDOM_TERTIARY_THEME",
"RANDOM_CONSTRAINTS",
"RANDOM_CONSTRAINTS_PATH",
"RANDOM_OUTPUT_JSON",
):
if os.getenv(env_name):
return True
noteworthy_keys = (
"theme",
"primary_theme",
"secondary_theme",
"tertiary_theme",
"seed",
"auto_fill",
"auto_fill_secondary",
"auto_fill_tertiary",
"strict_theme_match",
"attempts",
"timeout_ms",
"constraints",
"constraints_path",
"output_json",
)
if any(random_section.get(key) for key in noteworthy_keys):
return True
return False
def _resolve_random_config(args: argparse.Namespace, json_cfg: Dict[str, Any]) -> Tuple[RandomRunConfig, Dict[str, Any]]:
random_section = _extract_random_section(json_cfg)
cfg = RandomRunConfig()
cfg.legacy_theme = _resolve_random_str(
getattr(args, "random_theme", None),
"RANDOM_THEME",
random_section,
"theme",
None,
)
cfg.primary_theme = _resolve_random_str(
getattr(args, "random_primary_theme", None),
"RANDOM_PRIMARY_THEME",
random_section,
"primary_theme",
cfg.legacy_theme,
)
cfg.secondary_theme = _resolve_random_str(
getattr(args, "random_secondary_theme", None),
"RANDOM_SECONDARY_THEME",
random_section,
"secondary_theme",
None,
)
cfg.tertiary_theme = _resolve_random_str(
getattr(args, "random_tertiary_theme", None),
"RANDOM_TERTIARY_THEME",
random_section,
"tertiary_theme",
None,
)
auto_fill_flag = _resolve_random_bool(
getattr(args, "random_auto_fill", None),
"RANDOM_AUTO_FILL",
random_section,
"auto_fill",
False,
)
cfg.auto_fill_missing = bool(auto_fill_flag)
cfg.auto_fill_secondary = _resolve_random_bool(
getattr(args, "random_auto_fill_secondary", None),
"RANDOM_AUTO_FILL_SECONDARY",
random_section,
"auto_fill_secondary",
None,
)
cfg.auto_fill_tertiary = _resolve_random_bool(
getattr(args, "random_auto_fill_tertiary", None),
"RANDOM_AUTO_FILL_TERTIARY",
random_section,
"auto_fill_tertiary",
None,
)
cfg.strict_theme_match = bool(
_resolve_random_bool(
getattr(args, "random_strict_theme_match", None),
"RANDOM_STRICT_THEME_MATCH",
random_section,
"strict_theme_match",
False,
)
)
cfg.attempts = max(
1,
_resolve_random_int(
getattr(args, "random_attempts", None),
"RANDOM_MAX_ATTEMPTS",
random_section,
"attempts",
5,
),
)
cfg.timeout_ms = max(
100,
_resolve_random_int(
getattr(args, "random_timeout_ms", None),
"RANDOM_TIMEOUT_MS",
random_section,
"timeout_ms",
5000,
),
)
cfg.seed = _resolve_random_seed(getattr(args, "random_seed", None), random_section)
# Resolve constraints in precedence order: CLI > env JSON > env path > config dict > config path
constraints_candidates: Tuple[Any, ...] = (
getattr(args, "random_constraints", None),
os.getenv("RANDOM_CONSTRAINTS"),
os.getenv("RANDOM_CONSTRAINTS_PATH"),
random_section.get("constraints"),
random_section.get("constraints_path"),
)
for candidate in constraints_candidates:
loaded = _load_constraints_spec(candidate)
if loaded:
cfg.constraints = loaded
break
cfg.output_json = _resolve_random_str(
getattr(args, "random_output_json", None),
"RANDOM_OUTPUT_JSON",
random_section,
"output_json",
None,
)
if cfg.primary_theme is None:
cfg.primary_theme = cfg.legacy_theme
if cfg.primary_theme and not cfg.legacy_theme:
cfg.legacy_theme = cfg.primary_theme
if cfg.auto_fill_missing:
if cfg.auto_fill_secondary is None:
cfg.auto_fill_secondary = True
if cfg.auto_fill_tertiary is None:
cfg.auto_fill_tertiary = True
return cfg, random_section
def _print_random_summary(result: Any, config: RandomRunConfig) -> None:
print("\n" + "=" * 60)
print("RANDOM MODE BUILD")
print("=" * 60)
commander = getattr(result, "commander", None) or "(unknown)"
print(f"Commander : {commander}")
seed_value = getattr(result, "seed", config.seed)
print(f"Seed : {seed_value}")
display_themes = list(getattr(result, "display_themes", []) or [])
if not display_themes:
primary = getattr(result, "primary_theme", config.primary_theme)
if primary:
display_themes.append(primary)
for extra in (
getattr(result, "secondary_theme", config.secondary_theme),
getattr(result, "tertiary_theme", config.tertiary_theme),
):
if extra:
display_themes.append(extra)
if display_themes:
print(f"Themes : {', '.join(display_themes)}")
else:
print("Themes : (none)")
fallback_kinds: List[str] = []
if getattr(result, "combo_fallback", False):
fallback_kinds.append("combo")
if getattr(result, "synergy_fallback", False):
fallback_kinds.append("synergy")
fallback_reason = getattr(result, "fallback_reason", None)
print(f"Fallback : {('/'.join(fallback_kinds)) if fallback_kinds else 'none'}")
if fallback_reason:
print(f"Fallback reason : {fallback_reason}")
auto_secondary = getattr(result, "auto_fill_secondary_enabled", config.auto_fill_secondary or False)
auto_tertiary = getattr(result, "auto_fill_tertiary_enabled", config.auto_fill_tertiary or False)
print(
"Auto-fill : secondary={} | tertiary={}".format(
"on" if auto_secondary else "off",
"on" if auto_tertiary else "off",
)
)
print(f"Strict match : {'on' if config.strict_theme_match else 'off'}")
attempts_used = getattr(result, "attempts_tried", None)
if attempts_used is None:
attempts_used = config.attempts
print(f"Attempts used : {attempts_used} / {config.attempts}")
timeout_hit = getattr(result, "timeout_hit", False)
print(f"Timeout (ms) : {config.timeout_ms} (timeout_hit={timeout_hit})")
if config.constraints:
try:
print("Constraints :")
print(json.dumps(config.constraints, indent=2))
except Exception:
print(f"Constraints : {config.constraints}")
csv_path = getattr(result, "csv_path", None)
if csv_path:
print(f"Deck CSV : {csv_path}")
txt_path = getattr(result, "txt_path", None)
if txt_path:
print(f"Deck TXT : {txt_path}")
compliance = getattr(result, "compliance", None)
if compliance:
if isinstance(compliance, dict) and compliance.get("path"):
print(f"Compliance JSON : {compliance['path']}")
else:
try:
print("Compliance data :")
print(json.dumps(compliance, indent=2))
except Exception:
print(f"Compliance data : {compliance}")
summary = getattr(result, "summary", None)
if summary:
try:
rendered = json.dumps(summary, indent=2)
except Exception:
rendered = str(summary)
preview = rendered[:1000]
print("Summary preview :")
print(preview + ("..." if len(rendered) > len(preview) else ""))
decklist = getattr(result, "decklist", None)
if decklist:
try:
print(f"Decklist cards : {len(decklist)}")
except Exception:
pass
print("=" * 60)
def _write_random_payload(config: RandomRunConfig, result: Any) -> None:
if not config.output_json:
return
try:
path = _resolve_pathish_target(config.output_json, getattr(result, "seed", config.seed))
except Exception as exc:
print(f"Warning: unable to resolve random output path '{config.output_json}': {exc}")
return
seed_value = getattr(result, "seed", config.seed)
try:
normalized_seed = int(seed_value) if seed_value is not None else None
except Exception:
normalized_seed = seed_value
payload: Dict[str, Any] = {
"seed": normalized_seed,
"commander": getattr(result, "commander", None),
"themes": {
"primary": getattr(result, "primary_theme", config.primary_theme),
"secondary": getattr(result, "secondary_theme", config.secondary_theme),
"tertiary": getattr(result, "tertiary_theme", config.tertiary_theme),
"resolved": list(getattr(result, "resolved_themes", []) or []),
"display": list(getattr(result, "display_themes", []) or []),
"auto_filled": list(getattr(result, "auto_filled_themes", []) or []),
},
"strict_theme_match": bool(config.strict_theme_match),
"auto_fill": {
"missing": bool(config.auto_fill_missing),
"secondary": bool(getattr(result, "auto_fill_secondary_enabled", config.auto_fill_secondary or False)),
"tertiary": bool(getattr(result, "auto_fill_tertiary_enabled", config.auto_fill_tertiary or False)),
"applied": bool(getattr(result, "auto_fill_applied", False)),
},
"attempts": {
"configured": config.attempts,
"used": int(getattr(result, "attempts_tried", config.attempts) or config.attempts),
"timeout_ms": config.timeout_ms,
"timeout_hit": bool(getattr(result, "timeout_hit", False)),
"retries_exhausted": bool(getattr(result, "retries_exhausted", False)),
},
"fallback": {
"combo": bool(getattr(result, "combo_fallback", False)),
"synergy": bool(getattr(result, "synergy_fallback", False)),
"reason": getattr(result, "fallback_reason", None),
},
"constraints": config.constraints,
"csv_path": getattr(result, "csv_path", None),
"txt_path": getattr(result, "txt_path", None),
"compliance": getattr(result, "compliance", None),
"summary": getattr(result, "summary", None),
"decklist": getattr(result, "decklist", None),
}
try:
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
print(f"Random build payload written to {path}")
except Exception as exc:
print(f"Warning: failed to write random payload '{path}': {exc}")
def _run_random_mode(config: RandomRunConfig) -> int:
try:
from deck_builder.random_entrypoint import (
RandomConstraintsImpossibleError,
RandomThemeNoMatchError,
build_random_full_deck,
) # type: ignore
except Exception as exc:
print(f"Random mode unavailable: {exc}")
return 1
timeout_ms = max(100, int(config.timeout_ms))
attempts = max(1, int(config.attempts))
try:
result = build_random_full_deck(
theme=config.legacy_theme,
constraints=config.constraints or None,
seed=config.seed,
attempts=attempts,
timeout_s=float(timeout_ms) / 1000.0,
primary_theme=config.primary_theme,
secondary_theme=config.secondary_theme,
tertiary_theme=config.tertiary_theme,
auto_fill_missing=config.auto_fill_missing,
auto_fill_secondary=config.auto_fill_secondary,
auto_fill_tertiary=config.auto_fill_tertiary,
strict_theme_match=config.strict_theme_match,
)
except RandomThemeNoMatchError as exc:
print(f"Random mode failed: strict theme match produced no results ({exc})")
return 3
except RandomConstraintsImpossibleError as exc:
print(f"Random mode constraints impossible: {exc}")
return 4
except Exception as exc:
print(f"Random mode encountered an unexpected error: {exc}")
return 1
_print_random_summary(result, config)
_write_random_payload(config, result)
return 0
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Headless deck builder runner")
p.add_argument("--config", metavar="PATH", default=os.getenv("DECK_CONFIG"),
@ -533,6 +1121,101 @@ def _build_arg_parser() -> argparse.ArgumentParser:
include_group.add_argument("--fuzzy-matching", metavar="BOOL", type=_parse_bool, default=None,
help="Enable fuzzy card name matching (bool: true/false/1/0)")
# Random mode configuration (parity with web random builder)
random_group = p.add_argument_group(
"Random Mode",
"Generate decks using the random web builder flow",
)
random_group.add_argument(
"--random-mode",
action="store_true",
help="Force random-mode build even if other inputs are provided",
)
random_group.add_argument(
"--random-theme",
metavar="THEME",
default=None,
help="Legacy random theme (maps to primary theme if unspecified)",
)
random_group.add_argument(
"--random-primary-theme",
metavar="THEME",
default=None,
help="Primary theme slug for random mode",
)
random_group.add_argument(
"--random-secondary-theme",
metavar="THEME",
default=None,
help="Secondary theme slug for random mode",
)
random_group.add_argument(
"--random-tertiary-theme",
metavar="THEME",
default=None,
help="Tertiary theme slug for random mode",
)
random_group.add_argument(
"--random-auto-fill",
metavar="BOOL",
type=_parse_bool,
default=None,
help="Enable auto-fill assistance for missing theme slots",
)
random_group.add_argument(
"--random-auto-fill-secondary",
metavar="BOOL",
type=_parse_bool,
default=None,
help="Enable auto-fill specifically for secondary theme",
)
random_group.add_argument(
"--random-auto-fill-tertiary",
metavar="BOOL",
type=_parse_bool,
default=None,
help="Enable auto-fill specifically for tertiary theme",
)
random_group.add_argument(
"--random-strict-theme-match",
metavar="BOOL",
type=_parse_bool,
default=None,
help="Require strict theme matches when selecting commanders",
)
random_group.add_argument(
"--random-attempts",
metavar="INT",
type=int,
default=None,
help="Maximum attempts before giving up (default 5)",
)
random_group.add_argument(
"--random-timeout-ms",
metavar="INT",
type=int,
default=None,
help="Timeout in milliseconds for theme search (default 5000)",
)
random_group.add_argument(
"--random-seed",
metavar="SEED",
default=None,
help="Seed value for deterministic random builds",
)
random_group.add_argument(
"--random-constraints",
metavar="JSON_OR_PATH",
default=None,
help="Random constraints as JSON or a path to a JSON file",
)
random_group.add_argument(
"--random-output-json",
metavar="PATH",
default=None,
help="Write random build payload JSON to PATH (directory or file)",
)
# Utility
p.add_argument("--dry-run", action="store_true",
help="Print resolved configuration and exit without building")
@ -584,6 +1267,13 @@ def _main() -> int:
os.environ["DECK_CONFIG"] = chosen
break
random_config, random_section = _resolve_random_config(args, json_cfg)
if _should_run_random_mode(args, json_cfg, random_section):
if args.dry_run:
print(json.dumps({"random_mode": True, "config": asdict(random_config)}, indent=2))
return 0
return _run_random_mode(random_config)
# Defaults mirror run() signature
defaults = dict(
command_name="",

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -51,6 +51,18 @@ services:
RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
RANDOM_TERTIARY_THEME: "" # optional tertiary theme slug override
RANDOM_AUTO_FILL: "0" # when 1, auto-fill missing secondary/tertiary themes
RANDOM_AUTO_FILL_SECONDARY: "" # override secondary auto-fill (blank inherits from RANDOM_AUTO_FILL)
RANDOM_AUTO_FILL_TERTIARY: "" # override tertiary auto-fill (blank inherits from RANDOM_AUTO_FILL)
RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches for commanders when 1
RANDOM_CONSTRAINTS: "" # inline JSON constraints for random builds (optional)
RANDOM_CONSTRAINTS_PATH: "" # path to JSON constraints file (takes precedence)
RANDOM_SEED: "" # deterministic random seed (int or string)
RANDOM_OUTPUT_JSON: "" # path or directory for random build output payload
# RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT: "1" # (now defaults to 1 automatically for random builds; set to 0 to force legacy double-export behavior)
# Theming
@ -80,7 +92,7 @@ services:
# WEB_THEME_FILTER_PREWARM: "0"
WEB_AUTO_ENFORCE: "0" # 1=auto-run compliance export after builds
WEB_CUSTOM_EXPORT_BASE: "" # Optional: custom base dir for deck export artifacts
APP_VERSION: "dev" # Displayed version label (set per release/tag)
APP_VERSION: "2.3.1" # Displayed version label (set per release/tag)
# ------------------------------------------------------------------
# Misc / Land Selection (Step 7) Environment Tuning
@ -144,6 +156,7 @@ services:
# DECK_TRIPLE_COUNT: ""
# DECK_UTILITY_COUNT: ""
# DECK_TAG_MODE: "AND" # AND|OR (if supported)
# HEADLESS_RANDOM_MODE: "0" # 1=force headless random mode instead of scripted build
# Entrypoint knobs (only if you change the entrypoint behavior)
# APP_MODE: "web" # web|cli — selects uvicorn vs CLI

View file

@ -42,6 +42,18 @@ services:
RANDOM_UI: "0" # 1=UI Surprise/Reroll controls
RANDOM_MAX_ATTEMPTS: "5" # Retry cap for constrained random builds
RANDOM_TIMEOUT_MS: "5000" # Per-attempt timeout (ms)
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
RANDOM_TERTIARY_THEME: "" # optional tertiary theme slug override
RANDOM_AUTO_FILL: "0" # when 1, auto-fill missing secondary/tertiary themes
RANDOM_AUTO_FILL_SECONDARY: "" # override secondary auto-fill (blank inherits from RANDOM_AUTO_FILL)
RANDOM_AUTO_FILL_TERTIARY: "" # override tertiary auto-fill (blank inherits from RANDOM_AUTO_FILL)
RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches when 1
RANDOM_CONSTRAINTS: "" # inline JSON constraints for random builds (optional)
RANDOM_CONSTRAINTS_PATH: "" # path to JSON constraints file (takes precedence)
RANDOM_SEED: "" # deterministic random seed (int or string)
RANDOM_OUTPUT_JSON: "" # path or directory for random build payload export
# Theming
THEME: "system" # system|light|dark default theme
@ -61,7 +73,7 @@ services:
# WEB_THEME_FILTER_PREWARM: "0"
WEB_AUTO_ENFORCE: "0" # 1=auto compliance JSON export after builds
WEB_CUSTOM_EXPORT_BASE: "" # Optional export base override
APP_VERSION: "v2.2.10" # Displayed in footer/health
APP_VERSION: "v2.3.1" # Displayed in footer/health
# ------------------------------------------------------------------
# Misc Land Selection Tuning (Step 7)
@ -112,6 +124,7 @@ services:
# DECK_TRIPLE_COUNT: ""
# DECK_UTILITY_COUNT: ""
# DECK_TAG_MODE: "AND"
# HEADLESS_RANDOM_MODE: "0" # 1=force headless random mode instead of scripted build
# ------------------------------------------------------------------
# Entrypoint / Server knobs
@ -158,7 +171,7 @@ services:
# THEME_PREVIEW_TTL_STEPS: "2,4,2,3,1" # step counts for band progression
# Redis backend (optional)
# THEME_PREVIEW_REDIS_URL: "redis://redis:6379/0"
# THEME_PREVIEW_REDIS_DISABLE: "0" # 1=force disable redis even if URL is set
# THEME_PREVIEW_REDIS_DISABLE: "0" # 1=force disable redis even if URL is set
volumes:
- ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs

View file

@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mtg-deckbuilder"
version = "2.3.0"
version = "2.3.1"
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
readme = "README.md"
license = {file = "LICENSE"}