feat(themes): whitelist governance, synergy cap, docs + tests; feat(random): laid roadwork for random implementation, testing in headless confirmed

This commit is contained in:
matt 2025-09-17 13:23:27 -07:00
parent 03e839fb87
commit 16261bbf09
34 changed files with 12594 additions and 23 deletions

View file

@ -74,6 +74,45 @@ class DeckBuilder(
ColorBalanceMixin,
ReportingMixin
):
# Seedable RNG support (minimal surface area):
# - seed: optional seed value stored for diagnostics
# - _rng: internal Random instance; access via self.rng
seed: Optional[int] = field(default=None, repr=False)
_rng: Any = field(default=None, repr=False)
@property
def rng(self):
"""Lazy, per-builder RNG instance. If a seed was set, use it deterministically."""
if self._rng is None:
try:
# If a seed was assigned pre-init, use it
if self.seed is not None:
# Import here to avoid any heavy import cycles at module import time
from random_util import set_seed as _set_seed # type: ignore
self._rng = _set_seed(int(self.seed))
else:
self._rng = random.Random()
except Exception:
# Fallback to module random
self._rng = random
return self._rng
def set_seed(self, seed: int | str) -> None:
"""Set deterministic seed for this builder and reset its RNG instance."""
try:
from random_util import derive_seed_from_string as _derive, set_seed as _set_seed # type: ignore
s = _derive(seed)
self.seed = int(s)
self._rng = _set_seed(s)
except Exception:
try:
self.seed = int(seed) if not isinstance(seed, int) else seed
r = random.Random()
r.seed(self.seed)
self._rng = r
except Exception:
# Leave RNG as-is on unexpected error
pass
def build_deck_full(self):
"""Orchestrate the full deck build process, chaining all major phases."""
start_ts = datetime.datetime.now()
@ -712,10 +751,8 @@ class DeckBuilder(
# RNG Initialization
# ---------------------------
def _get_rng(self): # lazy init
if self._rng is None:
import random as _r
self._rng = _r
return self._rng
# Delegate to seedable rng property for determinism support
return self.rng
# ---------------------------
# Data Loading
@ -1003,8 +1040,10 @@ class DeckBuilder(
self.determine_color_identity()
dfs = []
required = getattr(bc, 'CSV_REQUIRED_COLUMNS', [])
from path_util import csv_dir as _csv_dir
base = _csv_dir()
for stem in self.files_to_load:
path = f'csv_files/{stem}_cards.csv'
path = f"{base}/{stem}_cards.csv"
try:
df = pd.read_csv(path)
if required:

View file

@ -1,5 +1,6 @@
from typing import Dict, List, Final, Tuple, Union, Callable, Any as _Any
from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified
from path_util import csv_dir
__all__ = [
'CSV_REQUIRED_COLUMNS'
@ -13,7 +14,7 @@ MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices
# Commander-related constants
DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}'
COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv'
COMMANDER_CSV_PATH: Final[str] = f"{csv_dir()}/commander_cards.csv"
DECK_DIRECTORY = '../deck_files'
COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters
COMMANDER_POWER_DEFAULT: Final[int] = 0

View file

@ -121,7 +121,7 @@ class CreatureAdditionMixin:
if owned_lower and str(nm).lower() in owned_lower:
w *= owned_mult
weighted_pool.append((nm, w))
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap, rng=getattr(self, 'rng', None))
for nm in chosen_all:
if commander_name and nm == commander_name:
continue
@ -201,7 +201,7 @@ class CreatureAdditionMixin:
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
chosen = bu.weighted_sample_without_replacement(weighted_pool, target, rng=getattr(self, 'rng', None))
for nm in chosen:
if commander_name and nm == commander_name:
continue
@ -507,7 +507,7 @@ class CreatureAdditionMixin:
return
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
chosen = bu.weighted_sample_without_replacement(weighted_pool, target, rng=getattr(self, 'rng', None))
added = 0
for nm in chosen:
row = pool[pool['name']==nm].iloc[0]
@ -621,7 +621,7 @@ class CreatureAdditionMixin:
if owned_lower and str(nm).lower() in owned_lower:
w *= owned_mult
weighted_pool.append((nm, w))
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap, rng=getattr(self, 'rng', None))
added = 0
for nm in chosen_all:
row = subset_all[subset_all['name'] == nm].iloc[0]

View file

@ -139,7 +139,14 @@ class SpellAdditionMixin:
for name, entry in self.card_library.items():
if any(isinstance(t, str) and 'ramp' in t.lower() for t in entry.get('Tags', [])):
existing_ramp += 1
to_add, _bonus = bu.compute_adjusted_target('Ramp', target_total, existing_ramp, self.output_func, plural_word='ramp spells')
to_add, _bonus = bu.compute_adjusted_target(
'Ramp',
target_total,
existing_ramp,
self.output_func,
plural_word='ramp spells',
rng=getattr(self, 'rng', None)
)
if existing_ramp >= target_total and to_add == 0:
return
if existing_ramp < target_total:
@ -290,7 +297,14 @@ class SpellAdditionMixin:
lt = [str(t).lower() for t in entry.get('Tags', [])]
if any(('removal' in t or 'spot removal' in t) for t in lt) and not any(('board wipe' in t or 'mass removal' in t) for t in lt):
existing += 1
to_add, _bonus = bu.compute_adjusted_target('Removal', target, existing, self.output_func, plural_word='removal spells')
to_add, _bonus = bu.compute_adjusted_target(
'Removal',
target,
existing,
self.output_func,
plural_word='removal spells',
rng=getattr(self, 'rng', None)
)
if existing >= target and to_add == 0:
return
target = to_add if existing < target else to_add
@ -360,7 +374,14 @@ class SpellAdditionMixin:
tags = [str(t).lower() for t in entry.get('Tags', [])]
if any(('board wipe' in t or 'mass removal' in t) for t in tags):
existing += 1
to_add, _bonus = bu.compute_adjusted_target('Board wipe', target, existing, self.output_func, plural_word='wipes')
to_add, _bonus = bu.compute_adjusted_target(
'Board wipe',
target,
existing,
self.output_func,
plural_word='wipes',
rng=getattr(self, 'rng', None)
)
if existing >= target and to_add == 0:
return
target = to_add if existing < target else to_add
@ -407,7 +428,14 @@ class SpellAdditionMixin:
tags = [str(t).lower() for t in entry.get('Tags', [])]
if any(('draw' in t) or ('card advantage' in t) for t in tags):
existing += 1
to_add_total, _bonus = bu.compute_adjusted_target('Card advantage', total_target, existing, self.output_func, plural_word='draw spells')
to_add_total, _bonus = bu.compute_adjusted_target(
'Card advantage',
total_target,
existing,
self.output_func,
plural_word='draw spells',
rng=getattr(self, 'rng', None)
)
if existing >= total_target and to_add_total == 0:
return
total_target = to_add_total if existing < total_target else to_add_total
@ -540,7 +568,14 @@ class SpellAdditionMixin:
tags = [str(t).lower() for t in entry.get('Tags', [])]
if any('protection' in t for t in tags):
existing += 1
to_add, _bonus = bu.compute_adjusted_target('Protection', target, existing, self.output_func, plural_word='protection spells')
to_add, _bonus = bu.compute_adjusted_target(
'Protection',
target,
existing,
self.output_func,
plural_word='protection spells',
rng=getattr(self, 'rng', None)
)
if existing >= target and to_add == 0:
return
target = to_add if existing < target else to_add
@ -705,7 +740,7 @@ class SpellAdditionMixin:
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
chosen = bu.weighted_sample_without_replacement(weighted_pool, target, rng=getattr(self, 'rng', None))
for nm in chosen:
row = pool[pool['name'] == nm].iloc[0]
self.add_card(

View 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,
)