mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
feat(themes): whitelist governance, synergy cap, docs + tests; feat(random): laid roadwork for random implementation, testing in headless confirmed
This commit is contained in:
parent
03e839fb87
commit
16261bbf09
34 changed files with 12594 additions and 23 deletions
181
code/deck_builder/random_entrypoint.py
Normal file
181
code/deck_builder/random_entrypoint.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class RandomBuildResult:
|
||||
seed: int
|
||||
commander: str
|
||||
theme: Optional[str]
|
||||
constraints: Optional[Dict[str, Any]]
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def _filter_by_theme(df: pd.DataFrame, theme: Optional[str]) -> pd.DataFrame:
|
||||
if not theme:
|
||||
return df
|
||||
t = str(theme).strip().lower()
|
||||
try:
|
||||
mask = df.get("themeTags").apply(
|
||||
lambda tags: any(str(x).strip().lower() == t for x in (tags or []))
|
||||
)
|
||||
sub = df[mask]
|
||||
if len(sub) > 0:
|
||||
return sub
|
||||
except Exception:
|
||||
pass
|
||||
return df
|
||||
|
||||
|
||||
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,
|
||||
) -> 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)
|
||||
|
||||
# Load commander pool and apply theme filter (if any)
|
||||
df_all = _load_commanders_df()
|
||||
df = _filter_by_theme(df_all, theme)
|
||||
# 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"]
|
||||
|
||||
# Simple attempt/timeout loop (placeholder for future constraints checks)
|
||||
start = time.time()
|
||||
pick = None
|
||||
for _ in range(attempts):
|
||||
if (time.time() - start) > timeout_s:
|
||||
break
|
||||
idx = rng.randrange(0, len(names))
|
||||
candidate = names[idx]
|
||||
# For now, accept the first candidate; constraint hooks can be added here.
|
||||
pick = candidate
|
||||
break
|
||||
if pick is None:
|
||||
# Timeout/attempts exhausted; choose deterministically based on seed modulo
|
||||
pick = names[resolved_seed % len(names)]
|
||||
|
||||
return RandomBuildResult(seed=int(resolved_seed), commander=pick, theme=theme, constraints=constraints or {})
|
||||
|
||||
|
||||
__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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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}"},
|
||||
)
|
||||
|
||||
builder = _run(command_name=base.commander, seed=base.seed)
|
||||
|
||||
# Summarize the decklist from builder.card_library
|
||||
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 = []
|
||||
|
||||
diags: Dict[str, Any] = {"attempts": 1, "timeout_s": timeout_s}
|
||||
return RandomFullBuildResult(
|
||||
seed=base.seed,
|
||||
commander=base.commander,
|
||||
theme=base.theme,
|
||||
constraints=base.constraints or {},
|
||||
decklist=deck_items,
|
||||
diagnostics=diags,
|
||||
)
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue