mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
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