mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
"""Scoring & sampling configuration constants (Phase 2 extraction).
|
|
|
|
Centralizes knobs used by the sampling pipeline so future tuning (or
|
|
experimentation via environment variables) can occur without editing the
|
|
core algorithm code.
|
|
|
|
Public constants (import into sampling.py and tests):
|
|
COMMANDER_COLOR_FILTER_STRICT
|
|
COMMANDER_OVERLAP_BONUS
|
|
COMMANDER_THEME_MATCH_BONUS
|
|
SPLASH_OFF_COLOR_PENALTY
|
|
ROLE_BASE_WEIGHTS
|
|
ROLE_SATURATION_PENALTY
|
|
|
|
Helper functions:
|
|
rarity_weight_base() -> dict[str, float]
|
|
Returns per-rarity base weights (reads env each call to preserve
|
|
existing test expectations that patch env before invoking sampling).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Dict, Tuple, Optional
|
|
|
|
# Commander related bonuses (identical defaults to previous inline values)
|
|
COMMANDER_COLOR_FILTER_STRICT = True
|
|
COMMANDER_OVERLAP_BONUS = 1.8
|
|
COMMANDER_THEME_MATCH_BONUS = 0.9
|
|
|
|
# Penalties / bonuses
|
|
SPLASH_OFF_COLOR_PENALTY = -0.3
|
|
# Adaptive splash penalty feature flag & scaling factors.
|
|
# When SPLASH_ADAPTIVE=1 the effective penalty becomes:
|
|
# base_penalty * splash_adaptive_scale(color_count)
|
|
# Where color_count is the number of distinct commander colors (1-5).
|
|
# Default scale keeps existing behavior at 1-3 colors, softens at 4, much lighter at 5.
|
|
SPLASH_ADAPTIVE_ENABLED = os.getenv("SPLASH_ADAPTIVE", "0") == "1"
|
|
_DEFAULT_SPLASH_SCALE = "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35"
|
|
def parse_splash_adaptive_scale() -> Dict[int, float]: # dynamic to allow test env changes
|
|
spec = os.getenv("SPLASH_ADAPTIVE_SCALE", _DEFAULT_SPLASH_SCALE)
|
|
mapping: Dict[int, float] = {}
|
|
for part in spec.split(','):
|
|
part = part.strip()
|
|
if not part or ':' not in part:
|
|
continue
|
|
k_s, v_s = part.split(':', 1)
|
|
try:
|
|
k = int(k_s)
|
|
v = float(v_s)
|
|
if 1 <= k <= 5 and v > 0:
|
|
mapping[k] = v
|
|
except Exception:
|
|
continue
|
|
# Ensure all 1-5 present; fallback to 1.0 if unspecified
|
|
for i in range(1, 6):
|
|
mapping.setdefault(i, 1.0)
|
|
return mapping
|
|
ROLE_SATURATION_PENALTY = -0.4
|
|
|
|
# Base role weights applied inside score calculation
|
|
ROLE_BASE_WEIGHTS: Dict[str, float] = {
|
|
"payoff": 2.5,
|
|
"enabler": 2.0,
|
|
"support": 1.5,
|
|
"wildcard": 0.9,
|
|
}
|
|
|
|
# Rarity base weights (diminishing duplicate influence applied in sampling pipeline)
|
|
# Read from env at call time to allow tests to modify.
|
|
|
|
def rarity_weight_base() -> Dict[str, float]: # dynamic to allow env override per test
|
|
return {
|
|
"mythic": float(os.getenv("RARITY_W_MYTHIC", "1.2")),
|
|
"rare": float(os.getenv("RARITY_W_RARE", "0.9")),
|
|
"uncommon": float(os.getenv("RARITY_W_UNCOMMON", "0.65")),
|
|
"common": float(os.getenv("RARITY_W_COMMON", "0.4")),
|
|
}
|
|
|
|
__all__ = [
|
|
"COMMANDER_COLOR_FILTER_STRICT",
|
|
"COMMANDER_OVERLAP_BONUS",
|
|
"COMMANDER_THEME_MATCH_BONUS",
|
|
"SPLASH_OFF_COLOR_PENALTY",
|
|
"SPLASH_ADAPTIVE_ENABLED",
|
|
"parse_splash_adaptive_scale",
|
|
"ROLE_BASE_WEIGHTS",
|
|
"ROLE_SATURATION_PENALTY",
|
|
"rarity_weight_base",
|
|
"parse_rarity_diversity_targets",
|
|
"RARITY_DIVERSITY_OVER_PENALTY",
|
|
]
|
|
|
|
|
|
# Extended rarity diversity (optional) ---------------------------------------
|
|
# Env var RARITY_DIVERSITY_TARGETS pattern e.g. "mythic:0-1,rare:0-2,uncommon:0-4,common:0-6"
|
|
# Parsed into mapping rarity -> (min,max). Only max is enforced currently (penalty applied
|
|
# when overflow occurs); min reserved for potential future boosting logic.
|
|
|
|
RARITY_DIVERSITY_OVER_PENALTY = float(os.getenv("RARITY_DIVERSITY_OVER_PENALTY", "-0.5"))
|
|
|
|
def parse_rarity_diversity_targets() -> Optional[Dict[str, Tuple[int, int]]]:
|
|
spec = os.getenv("RARITY_DIVERSITY_TARGETS")
|
|
if not spec:
|
|
return None
|
|
targets: Dict[str, Tuple[int, int]] = {}
|
|
for part in spec.split(','):
|
|
part = part.strip()
|
|
if not part or ':' not in part:
|
|
continue
|
|
name, rng = part.split(':', 1)
|
|
name = name.strip().lower()
|
|
if '-' not in rng:
|
|
continue
|
|
lo_s, hi_s = rng.split('-', 1)
|
|
try:
|
|
lo = int(lo_s)
|
|
hi = int(hi_s)
|
|
if lo < 0 or hi < lo:
|
|
continue
|
|
targets[name] = (lo, hi)
|
|
except Exception:
|
|
continue
|
|
return targets or None
|