Release v1.1.0: headless runner + tagging updates (Discard Matters, Freerunning, Craft, Spree, Explore/Map, Rad, Energy/Resource Engine, Spawn/Scion)

This commit is contained in:
matt 2025-08-22 16:32:39 -07:00
parent 36abbaa1dd
commit 99005c19f8
23 changed files with 1330 additions and 420 deletions

View file

@ -1,11 +1,18 @@
"""Root package for the MTG deckbuilder source tree.
Adding this file ensures the directory is treated as a proper package so that
`python -m code.main` resolves to this project instead of the Python stdlib
module named `code` (which is a simple module, not a package).
If you still accidentally import the stdlib module, be sure you are executing
from the project root so the local `code` package is first on sys.path.
Ensures `python -m code.*` resolves to this project and adjusts sys.path so
legacy absolute imports like `import logging_util` (modules living under this
package) work whether you run files directly or as modules.
"""
from __future__ import annotations
import os
import sys
# Make the package directory importable as a top-level for legacy absolute imports
_PKG_DIR = os.path.dirname(__file__)
if _PKG_DIR and _PKG_DIR not in sys.path:
sys.path.insert(0, _PKG_DIR)
__all__ = []

View file

@ -103,6 +103,30 @@ class DeckBuilder(
txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
# Display the text file contents for easy copy/paste to online deck builders
self._display_txt_contents(txt_path)
# Also export a matching JSON config for replay (interactive builds only)
if not getattr(self, 'headless', False):
try:
# Choose config output dir: DECK_CONFIG dir > /app/config > ./config
import os as _os
cfg_path_env = _os.getenv('DECK_CONFIG')
cfg_dir = None
if cfg_path_env:
cfg_dir = _os.path.dirname(cfg_path_env) or '.'
elif _os.path.isdir('/app/config'):
cfg_dir = '/app/config'
else:
cfg_dir = 'config'
if cfg_dir:
_os.makedirs(cfg_dir, exist_ok=True)
self.export_run_config_json(directory=cfg_dir, filename=base + '.json') # type: ignore[attr-defined]
# Also, if DECK_CONFIG explicitly points to a file path, write exactly there too
if cfg_path_env:
cfg_dir2 = _os.path.dirname(cfg_path_env) or '.'
cfg_name2 = _os.path.basename(cfg_path_env)
_os.makedirs(cfg_dir2, exist_ok=True)
self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) # type: ignore[attr-defined]
except Exception:
pass
except Exception:
logger.warning("Plaintext export failed (non-fatal)")
end_ts = datetime.datetime.now()
@ -213,14 +237,18 @@ class DeckBuilder(
# IO injection for testing
input_func: Callable[[str], str] = field(default=lambda prompt: input(prompt))
output_func: Callable[[str], None] = field(default=lambda msg: print(msg))
# Deterministic random support
seed: Optional[int] = None
# Random support (no external seeding)
_rng: Any = field(default=None, repr=False)
# Logging / output behavior
log_outputs: bool = True # if True, mirror output_func messages into logger at INFO level
_original_output_func: Optional[Callable[[str], None]] = field(default=None, repr=False)
# Chosen land counts (only fetches are tracked/exported; others vary randomly)
fetch_count: Optional[int] = None
# Whether this build is running in headless mode (suppress some interactive-only exports)
headless: bool = False
def __post_init__(self):
"""Post-init hook to wrap the provided output function so that all user-facing
messages are also captured in the central log (at INFO level) unless disabled.
@ -250,10 +278,10 @@ class DeckBuilder(
# ---------------------------
# RNG Initialization
# ---------------------------
def _get_rng(self): # lazy init to allow seed set post-construction
def _get_rng(self): # lazy init
if self._rng is None:
import random as _r
self._rng = _r.Random(self.seed) if self.seed is not None else _r
self._rng = _r
return self._rng
# ---------------------------

View file

@ -114,6 +114,7 @@ class LandFetchMixin:
if len(chosen) < desired:
leftovers = [n for n in candidates if n not in chosen]
chosen.extend(leftovers[: desired - len(chosen)])
added: List[str] = []
for nm in chosen:
if self._current_land_count() >= land_target: # type: ignore[attr-defined]
@ -127,6 +128,11 @@ class LandFetchMixin:
added_by='lands_step4'
) # type: ignore[attr-defined]
added.append(nm)
# Record actual number of fetch lands added for export/replay context
try:
setattr(self, 'fetch_count', len(added)) # type: ignore[attr-defined]
except Exception:
pass
self.output_func("\nFetch Lands Added (Step 4):")
if not added:
self.output_func(" (None added)")

View file

@ -106,6 +106,7 @@ class LandMiscUtilityMixin:
self.add_card(nm, card_type='Land', role='utility', sub_role='misc', added_by='lands_step7')
added.append(nm)
self.output_func("\nMisc Utility Lands Added (Step 7):")
if not added:
self.output_func(" (None added)")

View file

@ -217,6 +217,7 @@ class LandTripleMixin:
)
added.append(name)
self.output_func("\nTriple Lands Added (Step 6):")
if not added:
self.output_func(" (None added)")

View file

@ -383,6 +383,89 @@ class ReportingMixin:
self.output_func(f"Plaintext deck list exported to {path}")
return path
def export_run_config_json(self, directory: str = 'config', filename: str | None = None, suppress_output: bool = False) -> str:
"""Export a JSON config capturing the key choices for replaying headless.
Filename mirrors CSV/TXT naming (same stem, .json extension).
Fields included:
- commander
- primary_tag / secondary_tag / tertiary_tag
- bracket_level (if chosen)
- use_multi_theme (default True)
- add_lands, add_creatures, add_non_creature_spells (defaults True)
- fetch_count (if determined during run)
- ideal_counts (the actual ideal composition values used)
"""
os.makedirs(directory, exist_ok=True)
def _slug(s: str) -> str:
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
return s2 or 'x'
def _unique_path(path: str) -> str:
if not os.path.exists(path):
return path
base, ext = os.path.splitext(path)
i = 1
while True:
candidate = f"{base}_{i}{ext}"
if not os.path.exists(candidate):
return candidate
i += 1
if filename is None:
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
themes: List[str] = []
if getattr(self, 'selected_tags', None):
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
else:
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if isinstance(t, str) and t.strip():
themes.append(t)
theme_parts = [_slug(t) for t in themes if t]
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
date_part = _dt.date.today().strftime('%Y%m%d')
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json"
path = _unique_path(os.path.join(directory, filename))
# Capture ideal counts (actual chosen values)
ideal_counts = getattr(self, 'ideal_counts', {}) or {}
# Capture fetch count (others vary run-to-run and are intentionally not recorded)
chosen_fetch = getattr(self, 'fetch_count', None)
payload = {
"commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '',
"primary_tag": getattr(self, 'primary_tag', None),
"secondary_tag": getattr(self, 'secondary_tag', None),
"tertiary_tag": getattr(self, 'tertiary_tag', None),
"bracket_level": getattr(self, 'bracket_level', None),
"use_multi_theme": True,
"add_lands": True,
"add_creatures": True,
"add_non_creature_spells": True,
# chosen fetch land count (others intentionally omitted for variance)
"fetch_count": chosen_fetch,
# actual ideal counts used for this run
"ideal_counts": {
k: int(v) for k, v in ideal_counts.items() if isinstance(v, (int, float))
}
# seed intentionally omitted
}
try:
import json as _json
with open(path, 'w', encoding='utf-8') as f:
_json.dump(payload, f, indent=2)
if not suppress_output:
self.output_func(f"Run config exported to {path}")
except Exception as e:
logger.warning(f"Failed to export run config: {e}")
return path
def print_card_library(self, table: bool = True):
"""Prints the current card library in either plain or tabular format.
Uses PrettyTable if available, otherwise prints a simple list.

View file

@ -22,8 +22,11 @@ from enum import Enum
import os
from typing import List, Dict, Any
# Third-party imports
import inquirer
# Third-party imports (optional)
try:
import inquirer # type: ignore
except Exception:
inquirer = None # Fallback to simple input-based menu when unavailable
import pandas as pd
# Local imports
@ -229,13 +232,32 @@ def _display_setup_menu() -> SetupOption:
Returns:
SetupOption: The selected menu option
"""
question: List[Dict[str, Any]] = [
inquirer.List(
'menu',
choices=[option.value for option in SetupOption],
carousel=True)]
answer = inquirer.prompt(question)
return SetupOption(answer['menu'])
if inquirer is not None:
question: List[Dict[str, Any]] = [
inquirer.List(
'menu',
choices=[option.value for option in SetupOption],
carousel=True)]
answer = inquirer.prompt(question)
return SetupOption(answer['menu'])
# Simple fallback when inquirer isn't installed (e.g., headless/container)
options = list(SetupOption)
print("\nSetup Menu:")
for idx, opt in enumerate(options, start=1):
print(f" {idx}) {opt.value}")
while True:
try:
sel = input("Select an option [1]: ").strip() or "1"
i = int(sel)
if 1 <= i <= len(options):
return options[i - 1]
except KeyboardInterrupt:
print("")
return SetupOption.BACK
except Exception:
pass
print("Invalid selection. Please try again.")
def setup() -> bool:
"""Run the setup process for the MTG Python Deckbuilder.

404
code/headless_runner.py Normal file
View file

@ -0,0 +1,404 @@
from __future__ import annotations
import argparse
import json
import os
from typing import Any, Dict, List, Optional
from pathlib import Path
from code.deck_builder.builder import DeckBuilder
"""Headless (non-interactive) runner.
Features:
- Script commander selection.
- Script primary / optional secondary / tertiary tags.
- Apply bracket & accept default ideal counts.
- Invoke multi-theme creature addition if available (fallback to primary-only).
Use run(..., secondary_choice=2, tertiary_choice=3, use_multi_theme=True) to exercise multi-theme logic.
Indices correspond to the numbered tag list presented during interaction.
"""
def run(
command_name: str = "Pantlaza",
add_creatures: bool = True,
add_non_creature_spells: bool = True,
# Fine-grained toggles (used only if add_non_creature_spells is False)
add_ramp: bool = True,
add_removal: bool = True,
add_wipes: bool = True,
add_card_advantage: bool = True,
add_protection: bool = True,
use_multi_theme: bool = True,
primary_choice: int = 2,
secondary_choice: Optional[int] = 2,
tertiary_choice: Optional[int] = 2,
add_lands: bool = True,
fetch_count: Optional[int] = 3,
dual_count: Optional[int] = None,
triple_count: Optional[int] = None,
utility_count: Optional[int] = None,
ideal_counts: Optional[Dict[str, int]] = None,
) -> DeckBuilder:
"""Run a scripted non-interactive deck build and return the DeckBuilder instance.
Integer parameters (primary_choice, secondary_choice, tertiary_choice) correspond to the
numeric indices shown during interactive tag selection. Pass None to omit secondary/tertiary.
Optional counts (fetch_count, dual_count, triple_count, utility_count) constrain land steps.
"""
scripted_inputs: List[str] = []
# Commander query & selection
scripted_inputs.append(command_name) # initial query
scripted_inputs.append("1") # choose first search match to inspect
scripted_inputs.append("y") # confirm commander
# Primary tag selection
scripted_inputs.append(str(primary_choice))
# Secondary tag selection or stop (0)
if secondary_choice is not None:
scripted_inputs.append(str(secondary_choice))
# Tertiary tag selection or stop (0)
if tertiary_choice is not None:
scripted_inputs.append(str(tertiary_choice))
else:
scripted_inputs.append("0")
else:
scripted_inputs.append("0") # stop at primary
# Bracket (meta power / style) selection; keeping existing scripted value
scripted_inputs.append("3")
# Ideal count prompts (press Enter for defaults)
for _ in range(8):
scripted_inputs.append("")
def scripted_input(prompt: str) -> str:
if scripted_inputs:
return scripted_inputs.pop(0)
raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt)
builder = DeckBuilder(input_func=scripted_input)
# Mark this run as headless so builder can adjust exports and logging
try:
builder.headless = True # type: ignore[attr-defined]
except Exception:
pass
# If ideal_counts are provided (from JSON), use them as the current defaults
# so the step 2 prompts will show these values and our blank entries will accept them.
if isinstance(ideal_counts, dict) and ideal_counts:
try:
ic: Dict[str, int] = {}
for k, v in ideal_counts.items():
try:
iv = int(v) if v is not None else None # type: ignore
except Exception:
continue
if iv is None:
continue
# Only accept known keys
if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}:
ic[k] = iv
if ic:
builder.ideal_counts.update(ic) # type: ignore[attr-defined]
except Exception:
pass
builder.run_initial_setup()
builder.run_deck_build_step1()
builder.run_deck_build_step2()
# Land sequence (optional)
if add_lands:
if hasattr(builder, 'run_land_step1'):
builder.run_land_step1() # Basics / initial
if hasattr(builder, 'run_land_step2'):
builder.run_land_step2() # Utility basics / rebalancing
if hasattr(builder, 'run_land_step3'):
builder.run_land_step3() # Kindred lands if applicable
if hasattr(builder, 'run_land_step4'):
builder.run_land_step4(requested_count=fetch_count)
if hasattr(builder, 'run_land_step5'):
builder.run_land_step5(requested_count=dual_count)
if hasattr(builder, 'run_land_step6'):
builder.run_land_step6(requested_count=triple_count)
if hasattr(builder, 'run_land_step7'):
builder.run_land_step7(requested_count=utility_count)
if hasattr(builder, 'run_land_step8'):
builder.run_land_step8()
if add_creatures:
builder.add_creatures()
# Non-creature spell categories (ramp / removal / wipes / draw / protection)
if add_non_creature_spells and hasattr(builder, 'add_non_creature_spells'):
builder.add_non_creature_spells()
else:
# Allow selective invocation if orchestrator not desired
if add_ramp and hasattr(builder, 'add_ramp'):
builder.add_ramp()
if add_removal and hasattr(builder, 'add_removal'):
builder.add_removal()
if add_wipes and hasattr(builder, 'add_board_wipes'):
builder.add_board_wipes()
if add_card_advantage and hasattr(builder, 'add_card_advantage'):
builder.add_card_advantage()
if add_protection and hasattr(builder, 'add_protection'):
builder.add_protection()
# Suppress verbose library print in headless run since CSV export is produced.
# builder.print_card_library()
builder.post_spell_land_adjust()
# Export decklist CSV (commander first word + date)
csv_path: Optional[str] = None
if hasattr(builder, 'export_decklist_csv'):
try:
csv_path = builder.export_decklist_csv()
except Exception:
csv_path = None
if hasattr(builder, 'export_decklist_text'):
try:
if csv_path:
base = os.path.splitext(os.path.basename(csv_path))[0]
builder.export_decklist_text(filename=base + '.txt')
if hasattr(builder, 'export_run_config_json'):
try:
cfg_path_env = os.getenv('DECK_CONFIG')
if cfg_path_env:
cfg_dir = os.path.dirname(cfg_path_env) or '.'
elif os.path.isdir('/app/config'):
cfg_dir = '/app/config'
else:
cfg_dir = 'config'
os.makedirs(cfg_dir, exist_ok=True)
builder.export_run_config_json(directory=cfg_dir, filename=base + '.json')
# If an explicit DECK_CONFIG path is given, also write to exactly that path
if cfg_path_env:
cfg_dir2 = os.path.dirname(cfg_path_env) or '.'
cfg_name2 = os.path.basename(cfg_path_env)
os.makedirs(cfg_dir2, exist_ok=True)
builder.export_run_config_json(directory=cfg_dir2, filename=cfg_name2)
except Exception:
pass
else:
builder.export_decklist_text()
except Exception:
pass
return builder
def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]:
if val is None:
return None
if isinstance(val, bool):
return val
if isinstance(val, int):
return bool(val)
s = str(val).strip().lower()
if s in {"1", "true", "t", "yes", "y", "on"}:
return True
if s in {"0", "false", "f", "no", "n", "off"}:
return False
return None
def _parse_opt_int(val: Optional[str | int]) -> Optional[int]:
if val is None:
return None
if isinstance(val, int):
return val
s = str(val).strip().lower()
if s in {"", "none", "null", "nan"}:
return None
return int(s)
def _load_json_config(path: Optional[str]) -> Dict[str, Any]:
if not path:
return {}
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("JSON config must be an object")
return data
except FileNotFoundError:
raise
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Headless deck builder runner")
p.add_argument("--config", default=os.getenv("DECK_CONFIG"), help="Path to JSON config file")
p.add_argument("--commander", default=None)
p.add_argument("--primary-choice", type=int, default=None)
p.add_argument("--secondary-choice", type=_parse_opt_int, default=None)
p.add_argument("--tertiary-choice", type=_parse_opt_int, default=None)
p.add_argument("--add-lands", type=_parse_bool, default=None)
p.add_argument("--fetch-count", type=_parse_opt_int, default=None)
p.add_argument("--dual-count", type=_parse_opt_int, default=None)
p.add_argument("--triple-count", type=_parse_opt_int, default=None)
p.add_argument("--utility-count", type=_parse_opt_int, default=None)
# no seed support
# Booleans
p.add_argument("--add-creatures", type=_parse_bool, default=None)
p.add_argument("--add-non-creature-spells", type=_parse_bool, default=None)
p.add_argument("--add-ramp", type=_parse_bool, default=None)
p.add_argument("--add-removal", type=_parse_bool, default=None)
p.add_argument("--add-wipes", type=_parse_bool, default=None)
p.add_argument("--add-card-advantage", type=_parse_bool, default=None)
p.add_argument("--add-protection", type=_parse_bool, default=None)
p.add_argument("--use-multi-theme", type=_parse_bool, default=None)
p.add_argument("--dry-run", action="store_true", help="Print resolved config and exit")
p.add_argument("--auto-select-config", action="store_true", help="If set, and multiple JSON configs exist, list and prompt to choose one before running.")
return p
def _resolve_value(
cli: Optional[Any], env_name: str, json_data: Dict[str, Any], json_key: str, default: Any
) -> Any:
if cli is not None:
return cli
env_val = os.getenv(env_name)
if env_val is not None:
# Convert types based on default type
if isinstance(default, bool):
b = _parse_bool(env_val)
return default if b is None else b
if isinstance(default, int) or default is None:
# allow optional ints
try:
return _parse_opt_int(env_val)
except ValueError:
return default
return env_val
if json_key in json_data:
return json_data[json_key]
return default
def _main() -> int:
parser = _build_arg_parser()
args = parser.parse_args()
# Optional config auto-discovery/prompting
cfg_path = args.config
json_cfg: Dict[str, Any] = {}
def _discover_json_configs() -> List[str]:
# Determine directory to scan for JSON configs
if cfg_path and os.path.isdir(cfg_path):
cfg_dir = cfg_path
elif os.path.isdir('/app/config'):
cfg_dir = '/app/config'
else:
cfg_dir = 'config'
try:
p = Path(cfg_dir)
return sorted([str(fp) for fp in p.glob('*.json')]) if p.exists() else []
except Exception:
return []
# If a file path is provided, load it directly
if cfg_path and os.path.isfile(cfg_path):
json_cfg = _load_json_config(cfg_path)
else:
# If auto-select is requested, we may prompt user to choose a config
configs = _discover_json_configs()
if cfg_path and os.path.isdir(cfg_path):
# Directory explicitly provided, prefer auto selection behavior
if len(configs) == 1:
json_cfg = _load_json_config(configs[0])
os.environ['DECK_CONFIG'] = configs[0]
elif len(configs) > 1 and args.auto_select_config:
def _label(p: str) -> str:
try:
with open(p, 'r', encoding='utf-8') as fh:
data = json.load(fh)
cmd = str(data.get('commander') or '').strip() or 'Unknown Commander'
themes = [t for t in [data.get('primary_tag'), data.get('secondary_tag'), data.get('tertiary_tag')] if isinstance(t, str) and t.strip()]
return f"{cmd} - {', '.join(themes)}" if themes else cmd
except Exception:
return p
print("\nAvailable JSON configs:")
for idx, f in enumerate(configs, start=1):
print(f" {idx}) {_label(f)}")
print(" 0) Cancel")
while True:
try:
sel = input("Select a config to run [0]: ").strip() or '0'
except KeyboardInterrupt:
print("")
sel = '0'
if sel == '0':
return 0
try:
i = int(sel)
if 1 <= i <= len(configs):
chosen = configs[i - 1]
json_cfg = _load_json_config(chosen)
os.environ['DECK_CONFIG'] = chosen
break
except ValueError:
pass
print("Invalid selection. Try again.")
else:
# No explicit file; if exactly one config exists, auto use it; else leave empty
if len(configs) == 1:
json_cfg = _load_json_config(configs[0])
os.environ['DECK_CONFIG'] = configs[0]
# Defaults mirror run() signature
defaults = dict(
command_name="Pantlaza",
add_creatures=True,
add_non_creature_spells=True,
add_ramp=True,
add_removal=True,
add_wipes=True,
add_card_advantage=True,
add_protection=True,
use_multi_theme=True,
primary_choice=2,
secondary_choice=2,
tertiary_choice=2,
add_lands=True,
fetch_count=3,
dual_count=None,
triple_count=None,
utility_count=None,
)
# Pull optional ideal_counts from JSON if present
ideal_counts_json = {}
try:
if isinstance(json_cfg.get("ideal_counts"), dict):
ideal_counts_json = json_cfg["ideal_counts"]
except Exception:
ideal_counts_json = {}
resolved = {
"command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]),
"add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]),
"add_non_creature_spells": _resolve_value(args.add_non_creature_spells, "DECK_ADD_NON_CREATURE_SPELLS", json_cfg, "add_non_creature_spells", defaults["add_non_creature_spells"]),
"add_ramp": _resolve_value(args.add_ramp, "DECK_ADD_RAMP", json_cfg, "add_ramp", defaults["add_ramp"]),
"add_removal": _resolve_value(args.add_removal, "DECK_ADD_REMOVAL", json_cfg, "add_removal", defaults["add_removal"]),
"add_wipes": _resolve_value(args.add_wipes, "DECK_ADD_WIPES", json_cfg, "add_wipes", defaults["add_wipes"]),
"add_card_advantage": _resolve_value(args.add_card_advantage, "DECK_ADD_CARD_ADVANTAGE", json_cfg, "add_card_advantage", defaults["add_card_advantage"]),
"add_protection": _resolve_value(args.add_protection, "DECK_ADD_PROTECTION", json_cfg, "add_protection", defaults["add_protection"]),
"use_multi_theme": _resolve_value(args.use_multi_theme, "DECK_USE_MULTI_THEME", json_cfg, "use_multi_theme", defaults["use_multi_theme"]),
"primary_choice": _resolve_value(args.primary_choice, "DECK_PRIMARY_CHOICE", json_cfg, "primary_choice", defaults["primary_choice"]),
"secondary_choice": _resolve_value(args.secondary_choice, "DECK_SECONDARY_CHOICE", json_cfg, "secondary_choice", defaults["secondary_choice"]),
"tertiary_choice": _resolve_value(args.tertiary_choice, "DECK_TERTIARY_CHOICE", json_cfg, "tertiary_choice", defaults["tertiary_choice"]),
"add_lands": _resolve_value(args.add_lands, "DECK_ADD_LANDS", json_cfg, "add_lands", defaults["add_lands"]),
"fetch_count": _resolve_value(args.fetch_count, "DECK_FETCH_COUNT", json_cfg, "fetch_count", defaults["fetch_count"]),
"dual_count": _resolve_value(args.dual_count, "DECK_DUAL_COUNT", json_cfg, "dual_count", defaults["dual_count"]),
"triple_count": _resolve_value(args.triple_count, "DECK_TRIPLE_COUNT", json_cfg, "triple_count", defaults["triple_count"]),
"utility_count": _resolve_value(args.utility_count, "DECK_UTILITY_COUNT", json_cfg, "utility_count", defaults["utility_count"]),
"ideal_counts": ideal_counts_json,
}
if args.dry_run:
print(json.dumps(resolved, indent=2))
return 0
run(**resolved)
return 0
if __name__ == "__main__":
raise SystemExit(_main())

View file

@ -8,6 +8,7 @@ from __future__ import annotations
# Standard library imports
import sys
from pathlib import Path
import json
from typing import NoReturn
# Local imports
@ -26,12 +27,7 @@ logger.addHandler(logging_util.stream_handler)
builder = DeckBuilder()
def run_menu() -> NoReturn:
"""Launch directly into the deck builder after ensuring data files exist.
Creates required directories, ensures card CSVs are present (running setup
and tagging if needed), then starts the full deck build flow. Exits when done.
"""
def _ensure_data_ready() -> None:
logger.info("Starting MTG Python Deckbuilder")
Path('csv_files').mkdir(parents=True, exist_ok=True)
Path('deck_files').mkdir(parents=True, exist_ok=True)
@ -47,6 +43,9 @@ def run_menu() -> NoReturn:
logger.info("Initial setup and tagging completed.")
except Exception as e:
logger.error(f"Failed ensuring CSVs are ready: {e}")
def _interactive_loop() -> None:
while True:
try:
# Fresh builder instance for each deck to avoid state carryover
@ -54,15 +53,157 @@ def run_menu() -> NoReturn:
except Exception as e:
logger.error(f"Unexpected error in deck builder: {e}")
# Prompt to build another deck or quit
# Prompt to build another deck or return to main menu
try:
resp = input("\nBuild another deck? (y/n): ").strip().lower()
except KeyboardInterrupt:
resp = 'n'
print("")
if resp not in ('y', 'yes'):
break
def run_menu() -> NoReturn:
"""Launch directly into the deck builder after ensuring data files exist.
Creates required directories, ensures card CSVs are present (running setup
and tagging if needed), then starts the full deck build flow. Exits when done.
"""
_ensure_data_ready()
# Auto headless mode for container runs (no menu prompt)
auto_mode = os.getenv('DECK_MODE', '').strip().lower()
if auto_mode in ("headless", "noninteractive", "auto"):
try:
from headless_runner import _main as headless_main
headless_main()
except Exception as e:
logger.error(f"Headless run failed: {e}")
logger.info("Exiting application")
sys.exit(0)
# Menu-driven selection
def _run_headless_with_config(selected_config: str | None) -> None:
"""Run headless runner, optionally forcing a specific config path for this invocation."""
try:
from headless_runner import _main as headless_main
# Temporarily override DECK_CONFIG for this run if provided
prev_cfg = os.environ.get('DECK_CONFIG')
try:
if selected_config:
os.environ['DECK_CONFIG'] = selected_config
headless_main()
finally:
if selected_config is not None:
if prev_cfg is not None:
os.environ['DECK_CONFIG'] = prev_cfg
else:
os.environ.pop('DECK_CONFIG', None)
except Exception as e:
logger.error(f"Headless run failed: {e}")
def _headless_submenu() -> None:
"""Submenu to choose a JSON config and run the headless builder.
Behavior:
- If DECK_CONFIG points to a file, run it immediately.
- Else, search for *.json in (DECK_CONFIG as dir) or /app/config or ./config.
- If one file is found, run it immediately.
- If multiple files, list them for selection.
- If none, fall back to running headless using env/CLI/defaults.
"""
cfg_target = os.getenv('DECK_CONFIG')
# Case 1: DECK_CONFIG is an explicit file
if cfg_target and os.path.isfile(cfg_target):
print(f"\nRunning headless with config: {cfg_target}")
_run_headless_with_config(cfg_target)
return
# Determine directory to scan for JSON configs
if cfg_target and os.path.isdir(cfg_target):
cfg_dir = cfg_target
elif os.path.isdir('/app/config'):
cfg_dir = '/app/config'
else:
cfg_dir = 'config'
try:
p = Path(cfg_dir)
files = sorted([str(fp) for fp in p.glob('*.json')]) if p.exists() else []
except Exception:
files = []
# No configs found: run headless with current env/CLI/defaults
if not files:
print("\nNo JSON configs found in '" + cfg_dir + "'. Running headless with env/CLI/defaults...")
_run_headless_with_config(None)
return
# Single config: run automatically
if len(files) == 1:
print(f"\nFound one JSON config: {files[0]}\nRunning it now...")
_run_headless_with_config(files[0])
return
# Multiple configs: list and select
def _config_label(p: str) -> str:
try:
with open(p, 'r', encoding='utf-8') as fh:
data = json.load(fh)
cmd = str(data.get('commander') or '').strip() or 'Unknown Commander'
themes = [t for t in [data.get('primary_tag'), data.get('secondary_tag'), data.get('tertiary_tag')] if isinstance(t, str) and t.strip()]
name = os.path.basename(p).lower()
if name == 'deck.json':
return 'Default'
return f"{cmd} - {', '.join(themes)}" if themes else cmd
except Exception:
return p
print("\nAvailable JSON configs:")
labels = [_config_label(f) for f in files]
for idx, label in enumerate(labels, start=1):
print(f" {idx}) {label}")
print(" 0) Back to main menu")
while True:
try:
sel = input("Select a config to run [0]: ").strip() or '0'
except KeyboardInterrupt:
print("")
sel = '0'
if sel == '0':
return
try:
i = int(sel)
if 1 <= i <= len(files):
_run_headless_with_config(files[i - 1])
return
except ValueError:
pass
print("Invalid selection. Try again.")
while True:
print("\n==== MTG Deckbuilder ====")
print("1) Interactive deck build")
print("2) Headless (env/JSON-configured) run")
print(" - Will auto-run a single config if found, or let you choose from many")
print("q) Quit")
try:
choice = input("Select an option [1]: ").strip().lower() or '1'
except KeyboardInterrupt:
print("")
choice = 'q'
if choice in ('1', 'i', 'interactive'):
_interactive_loop()
# loop returns to main menu
elif choice in ('2', 'h', 'headless', 'noninteractive'):
_headless_submenu()
# after one headless run, return to menu
elif choice in ('q', 'quit', 'exit'):
logger.info("Exiting application")
sys.exit(0)
else:
print("Invalid selection. Please try again.")
if __name__ == "__main__":
run_menu()

View file

@ -1,128 +0,0 @@
from __future__ import annotations
from typing import List, Optional
from deck_builder.builder import DeckBuilder
"""Non-interactive harness.
Features:
- Script commander selection.
- Script primary / optional secondary / tertiary tags.
- Apply bracket & accept default ideal counts.
- Invoke multi-theme creature addition if available (fallback to primary-only).
Use run(..., secondary_choice=2, tertiary_choice=3, use_multi_theme=True) to exercise multi-theme logic.
Indices correspond to the numbered tag list presented during interaction.
"""
def run(
command_name: str = "Pantlaza",
add_creatures: bool = True,
add_non_creature_spells: bool = True,
# Fine-grained toggles (used only if add_non_creature_spells is False)
add_ramp: bool = True,
add_removal: bool = True,
add_wipes: bool = True,
add_card_advantage: bool = True,
add_protection: bool = True,
use_multi_theme: bool = True,
primary_choice: int = 2,
secondary_choice: Optional[int] = 2,
tertiary_choice: Optional[int] = 2,
add_lands: bool = True,
fetch_count: Optional[int] = 3,
dual_count: Optional[int] = None,
triple_count: Optional[int] = None,
utility_count: Optional[int] = None,
seed: Optional[int] = None,
) -> DeckBuilder:
"""Run a scripted non-interactive deck build and return the DeckBuilder instance.
Integer parameters (primary_choice, secondary_choice, tertiary_choice) correspond to the
numeric indices shown during interactive tag selection. Pass None to omit secondary/tertiary.
Optional counts (fetch_count, dual_count, triple_count, utility_count) constrain land steps.
seed: optional deterministic RNG seed for reproducible builds.
"""
scripted_inputs: List[str] = []
# Commander query & selection
scripted_inputs.append(command_name) # initial query
scripted_inputs.append("1") # choose first search match to inspect
scripted_inputs.append("y") # confirm commander
# Primary tag selection
scripted_inputs.append(str(primary_choice))
# Secondary tag selection or stop (0)
if secondary_choice is not None:
scripted_inputs.append(str(secondary_choice))
# Tertiary tag selection or stop (0)
if tertiary_choice is not None:
scripted_inputs.append(str(tertiary_choice))
else:
scripted_inputs.append("0")
else:
scripted_inputs.append("0") # stop at primary
# Bracket (meta power / style) selection; keeping existing scripted value
scripted_inputs.append("3")
# Ideal count prompts (press Enter for defaults)
for _ in range(8):
scripted_inputs.append("")
def scripted_input(prompt: str) -> str:
if scripted_inputs:
return scripted_inputs.pop(0)
raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt)
builder = DeckBuilder(input_func=scripted_input, seed=seed)
builder.run_initial_setup()
builder.run_deck_build_step1()
builder.run_deck_build_step2()
# Land sequence (optional)
if add_lands:
if hasattr(builder, 'run_land_step1'):
builder.run_land_step1() # Basics / initial
if hasattr(builder, 'run_land_step2'):
builder.run_land_step2() # Utility basics / rebalancing
if hasattr(builder, 'run_land_step3'):
builder.run_land_step3() # Kindred lands if applicable
if hasattr(builder, 'run_land_step4'):
builder.run_land_step4(requested_count=fetch_count)
if hasattr(builder, 'run_land_step5'):
builder.run_land_step5(requested_count=dual_count)
if hasattr(builder, 'run_land_step6'):
builder.run_land_step6(requested_count=triple_count)
if hasattr(builder, 'run_land_step7'):
builder.run_land_step7(requested_count=utility_count)
if hasattr(builder, 'run_land_step8'):
builder.run_land_step8()
if add_creatures:
builder.add_creatures()
# Non-creature spell categories (ramp / removal / wipes / draw / protection)
if add_non_creature_spells and hasattr(builder, 'add_non_creature_spells'):
builder.add_non_creature_spells()
else:
# Allow selective invocation if orchestrator not desired
if add_ramp and hasattr(builder, 'add_ramp'):
builder.add_ramp()
if add_removal and hasattr(builder, 'add_removal'):
builder.add_removal()
if add_wipes and hasattr(builder, 'add_board_wipes'):
builder.add_board_wipes()
if add_card_advantage and hasattr(builder, 'add_card_advantage'):
builder.add_card_advantage()
if add_protection and hasattr(builder, 'add_protection'):
builder.add_protection()
# Suppress verbose library print in non-interactive run since CSV export is produced.
# builder.print_card_library()
builder.post_spell_land_adjust()
# Export decklist CSV (commander first word + date)
if hasattr(builder, 'export_decklist_csv'):
builder.export_decklist_csv()
return builder
if __name__ == "__main__":
run()

View file

@ -115,16 +115,34 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
## Tag for various effects
tag_for_cost_reduction(df, color)
print('\n====================\n')
# Freerunning is a keyworded cost-reduction mechanic
tag_for_freerunning(df, color)
print('\n====================\n')
tag_for_card_draw(df, color)
print('\n====================\n')
# Discard-centric effects and triggers
tag_for_discard_matters(df, color)
print('\n====================\n')
# Explore and Map tokens provide selection and incidental counters
tag_for_explore_and_map(df, color)
print('\n====================\n')
tag_for_artifacts(df, color)
print('\n====================\n')
tag_for_enchantments(df, color)
print('\n====================\n')
# Craft is a transform mechanic that often references artifacts, exile, and graveyards
tag_for_craft(df, color)
print('\n====================\n')
tag_for_exile_matters(df, color)
print('\n====================\n')
# Custom keywords/mechanics
tag_for_bending(df, color)
print('\n====================\n')
tag_for_tokens(df, color)
print('\n====================\n')
# Rad counters are tracked separately to surface the theme
tag_for_rad_counters(df, color)
print('\n====================\n')
tag_for_life_matters(df, color)
print('\n====================\n')
tag_for_counters(df, color)
@ -135,6 +153,9 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
print('\n====================\n')
tag_for_spellslinger(df, color)
print('\n====================\n')
# Spree spells are modal and cost-scale via additional payments
tag_for_spree(df, color)
print('\n====================\n')
tag_for_ramp(df, color)
print('\n====================\n')
tag_for_themes(df, color)
@ -810,19 +831,19 @@ def tag_for_loot_effects(df: pd.DataFrame, color: str) -> None:
# Apply tags based on masks
if loot_mask.any():
tag_utils.apply_tag_vectorized(df, loot_mask, ['Loot', 'Card Draw'])
tag_utils.apply_tag_vectorized(df, loot_mask, ['Loot', 'Card Draw', 'Discard Matters'])
logger.info(f'Tagged {loot_mask.sum()} cards with standard loot effects')
if connive_mask.any():
tag_utils.apply_tag_vectorized(df, connive_mask, ['Connive', 'Loot', 'Card Draw'])
tag_utils.apply_tag_vectorized(df, connive_mask, ['Connive', 'Loot', 'Card Draw', 'Discard Matters'])
logger.info(f'Tagged {connive_mask.sum()} cards with connive effects')
if cycling_mask.any():
tag_utils.apply_tag_vectorized(df, cycling_mask, ['Cycling', 'Loot', 'Card Draw'])
tag_utils.apply_tag_vectorized(df, cycling_mask, ['Cycling', 'Loot', 'Card Draw', 'Discard Matters'])
logger.info(f'Tagged {cycling_mask.sum()} cards with cycling effects')
if blood_mask.any():
tag_utils.apply_tag_vectorized(df, blood_mask, ['Blood Tokens', 'Loot', 'Card Draw'])
tag_utils.apply_tag_vectorized(df, blood_mask, ['Blood Tokens', 'Loot', 'Card Draw', 'Discard Matters'])
logger.info(f'Tagged {blood_mask.sum()} cards with blood token effects')
logger.info('Completed tagging loot-like effects')
@ -2136,10 +2157,19 @@ def tag_for_exile_matters(df: pd.DataFrame, color: str) -> None:
logger.info('Completed Suspend tagging')
print('\n==========\n')
tag_for_warp(df, color)
logger.info('Completed Warp tagging')
print('\n==========\n')
# New: Time counters and Time Travel support
tag_for_time_counters(df, color)
logger.info('Completed Time Counters tagging')
print('\n==========\n')
# Log completion and performance metrics
duration = pd.Timestamp.now() - start_time
logger.info(f'Completed all "Exile Matters" tagging in {duration.total_seconds():.2f}s')
except Exception as e:
logger.error(f'Error in tag_for_exile_matters: {str(e)}')
raise
@ -2471,6 +2501,98 @@ def tag_for_suspend(df: pd.DataFrame, color: str) -> None:
logger.info('Completed tagging Suspend cards')
## Cards that have or care about Warp
def tag_for_warp(df: pd.DataFrame, color: str) -> None:
"""Tag cards with Warp using vectorized operations.
Args:
df: DataFrame containing card data
color: Color identifier for logging purposes
"""
logger.info(f'Tagging Warp cards in {color}_cards.csv')
start_time = pd.Timestamp.now()
try:
# Create masks for Warp
keyword_mask = tag_utils.create_keyword_mask(df, 'Warp')
text_mask = tag_utils.create_text_mask(df, 'Warp')
final_mask = keyword_mask | text_mask
tag_utils.apply_rules(df, [{ 'mask': final_mask, 'tags': ['Warp', 'Exile Matters'] }])
duration = (pd.Timestamp.now() - start_time).total_seconds()
logger.info(f'Tagged {final_mask.sum()} Warp cards in {duration:.2f}s')
except Exception as e:
logger.error(f'Error tagging Warp cards: {str(e)}')
raise
logger.info('Completed tagging Warp cards')
def create_time_counters_mask(df: pd.DataFrame) -> pd.Series:
"""Create a boolean mask for cards that mention time counters or Time Travel.
This captures interactions commonly associated with Suspend without
requiring the Suspend keyword (e.g., Time Travel effects, adding/removing
time counters, or Vanishing).
Args:
df: DataFrame to search
Returns:
Boolean Series indicating which cards interact with time counters
"""
# Text patterns around time counters and time travel
text_patterns = [
'time counter',
'time counters',
'remove a time counter',
'add a time counter',
'time travel'
]
text_mask = tag_utils.create_text_mask(df, text_patterns)
# Keyword-based patterns that imply time counters
keyword_mask = tag_utils.create_keyword_mask(df, ['Vanishing'])
return text_mask | keyword_mask
def tag_for_time_counters(df: pd.DataFrame, color: str) -> None:
"""Tag cards that interact with time counters or Time Travel.
Applies a base 'Time Counters' tag. Adds 'Exile Matters' when the card also
mentions exile or Suspend, since those imply interaction with suspended
cards in exile.
Args:
df: DataFrame containing card data
color: Color identifier for logging purposes
"""
logger.info(f'Tagging Time Counters interactions in {color}_cards.csv')
start_time = pd.Timestamp.now()
try:
time_mask = create_time_counters_mask(df)
if not time_mask.any():
logger.info('No Time Counters interactions found')
return
# Always tag Time Counters
tag_utils.apply_rules(df, [{ 'mask': time_mask, 'tags': ['Time Counters'] }])
# Conditionally add Exile Matters if the card references exile or suspend
exile_mask = tag_utils.create_text_mask(df, tag_constants.PATTERN_GROUPS['exile'])
suspend_mask = tag_utils.create_keyword_mask(df, 'Suspend') | tag_utils.create_text_mask(df, 'Suspend')
time_exile_mask = time_mask & (exile_mask | suspend_mask)
if time_exile_mask.any():
tag_utils.apply_rules(df, [{ 'mask': time_exile_mask, 'tags': ['Exile Matters'] }])
duration = (pd.Timestamp.now() - start_time).total_seconds()
logger.info('Completed Time Counters tagging in %.2fs', duration)
except Exception as e:
logger.error(f'Error tagging Time Counters interactions: {str(e)}')
raise
### Tokens
def create_creature_token_mask(df: pd.DataFrame) -> pd.Series:
"""Create a boolean mask for cards that create creature tokens.
@ -2591,6 +2713,19 @@ def tag_for_tokens(df: pd.DataFrame, color: str) -> None:
{ 'mask': matters_mask, 'tags': ['Tokens Matter'] },
])
# Eldrazi Spawn/Scion special-casing: add Aristocrats and Ramp synergy tags
spawn_patterns = [
'eldrazi spawn creature token',
'eldrazi scion creature token',
'spawn creature token with "sacrifice',
'scion creature token with "sacrifice'
]
spawn_scion_mask = tag_utils.create_text_mask(df, spawn_patterns)
if spawn_scion_mask.any():
tag_utils.apply_rules(df, [
{ 'mask': spawn_scion_mask, 'tags': ['Aristocrats', 'Ramp'] }
])
# Logging
if creature_mask.any():
logger.info('Tagged %d cards that create creature tokens', creature_mask.sum())
@ -2606,6 +2741,162 @@ def tag_for_tokens(df: pd.DataFrame, color: str) -> None:
logger.error('Error tagging token cards: %s', str(e))
raise
### Freerunning (cost reduction variant)
def tag_for_freerunning(df: pd.DataFrame, color: str) -> None:
"""Tag cards that reference the Freerunning mechanic.
Adds Cost Reduction to ensure consistency, and a specific Freerunning tag for filtering.
"""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
mask = tag_utils.create_keyword_mask(df, 'Freerunning') | tag_utils.create_text_mask(df, ['freerunning', 'free running'])
if mask.any():
tag_utils.apply_rules(df, [
{ 'mask': mask, 'tags': ['Cost Reduction', 'Freerunning'] }
])
logger.info('Tagged %d Freerunning cards', mask.sum())
except Exception as e:
logger.error('Error tagging Freerunning: %s', str(e))
raise
### Craft (transform mechanic with exile/graveyard/artifact hooks)
def tag_for_craft(df: pd.DataFrame, color: str) -> None:
"""Tag cards with Craft. Adds Transform; conditionally adds Artifacts Matter, Exile Matters, and Graveyard Matters."""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
craft_mask = tag_utils.create_keyword_mask(df, 'Craft') | tag_utils.create_text_mask(df, ['craft with', 'craft —', ' craft '])
if craft_mask.any():
rules = [{ 'mask': craft_mask, 'tags': ['Transform'] }]
# Conditionals
artifact_cond = craft_mask & tag_utils.create_text_mask(df, ['artifact', 'artifacts'])
exile_cond = craft_mask & tag_utils.create_text_mask(df, ['exile'])
gy_cond = craft_mask & tag_utils.create_text_mask(df, ['graveyard'])
if artifact_cond.any():
rules.append({ 'mask': artifact_cond, 'tags': ['Artifacts Matter'] })
if exile_cond.any():
rules.append({ 'mask': exile_cond, 'tags': ['Exile Matters'] })
if gy_cond.any():
rules.append({ 'mask': gy_cond, 'tags': ['Graveyard Matters'] })
tag_utils.apply_rules(df, rules)
logger.info('Tagged %d Craft cards', craft_mask.sum())
except Exception as e:
logger.error('Error tagging Craft: %s', str(e))
raise
### Spree (modal, cost-scaling spells)
def tag_for_spree(df: pd.DataFrame, color: str) -> None:
"""Tag Spree spells with Modal and Cost Scaling."""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
mask = tag_utils.create_keyword_mask(df, 'Spree') | tag_utils.create_text_mask(df, ['spree'])
if mask.any():
tag_utils.apply_rules(df, [
{ 'mask': mask, 'tags': ['Modal', 'Cost Scaling'] }
])
logger.info('Tagged %d Spree cards', mask.sum())
except Exception as e:
logger.error('Error tagging Spree: %s', str(e))
raise
### Explore and Map tokens
def tag_for_explore_and_map(df: pd.DataFrame, color: str) -> None:
"""Tag Explore and Map token interactions.
- Explore: add Card Selection; if it places +1/+1 counters, add +1/+1 Counters
- Map Tokens: add Card Selection and Tokens Matter
"""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
explore_mask = tag_utils.create_keyword_mask(df, 'Explore') | tag_utils.create_text_mask(df, ['explores', 'explore.'])
map_mask = tag_utils.create_text_mask(df, ['map token', 'map tokens'])
rules = []
if explore_mask.any():
rules.append({ 'mask': explore_mask, 'tags': ['Card Selection'] })
# If the text also references +1/+1 counters, add that theme
explore_counters = explore_mask & tag_utils.create_text_mask(df, ['+1/+1 counter'])
if explore_counters.any():
rules.append({ 'mask': explore_counters, 'tags': ['+1/+1 Counters'] })
if map_mask.any():
rules.append({ 'mask': map_mask, 'tags': ['Card Selection', 'Tokens Matter'] })
if rules:
tag_utils.apply_rules(df, rules)
total = (explore_mask.astype(int) + map_mask.astype(int)).astype(bool).sum()
logger.info('Tagged %d Explore/Map cards', total)
except Exception as e:
logger.error('Error tagging Explore/Map: %s', str(e))
raise
### Rad counters
def tag_for_rad_counters(df: pd.DataFrame, color: str) -> None:
"""Tag Rad counter interactions as a dedicated theme."""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
rad_mask = tag_utils.create_text_mask(df, ['rad counter', 'rad counters'])
if rad_mask.any():
tag_utils.apply_rules(df, [ { 'mask': rad_mask, 'tags': ['Rad Counters'] } ])
logger.info('Tagged %d Rad counter cards', rad_mask.sum())
except Exception as e:
logger.error('Error tagging Rad counters: %s', str(e))
raise
### Discard Matters
def tag_for_discard_matters(df: pd.DataFrame, color: str) -> None:
"""Tag cards that discard or care about discarding.
Adds Discard Matters for:
- Text that makes you discard a card (costs or effects)
- Triggers on discarding
Also adds Loot where applicable is handled elsewhere; this focuses on the theme surface.
"""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
# Events where YOU discard (as part of a cost or effect). Keep generic 'discard a card' but filter out opponent/each-player cases.
discard_action_patterns = [
r'you discard (?:a|one|two|three|x) card',
r'discard (?:a|one|two|three|x) card',
r'discard your hand',
r'as an additional cost to (?:cast this spell|activate this ability),? discard (?:a|one) card',
r'as an additional cost,? discard (?:a|one) card'
]
action_mask = tag_utils.create_text_mask(df, discard_action_patterns)
exclude_opponent_patterns = [
r'target player discards',
r'target opponent discards',
r'each player discards',
r'each opponent discards',
r'that player discards'
]
exclude_mask = tag_utils.create_text_mask(df, exclude_opponent_patterns)
# Triggers/conditions that care when you discard
discard_trigger_patterns = [
r'whenever you discard',
r'if you discarded',
r'for each card you discarded',
r'when you discard'
]
trigger_mask = tag_utils.create_text_mask(df, discard_trigger_patterns)
# Blood tokens enable rummage (discard), and Madness explicitly cares about discarding
blood_patterns = [r'create (?:a|one|two|three|x|\d+) blood token']
blood_mask = tag_utils.create_text_mask(df, blood_patterns)
madness_mask = tag_utils.create_text_mask(df, [r'\bmadness\b'])
final_mask = ((action_mask & ~exclude_mask) | trigger_mask | blood_mask | madness_mask)
if final_mask.any():
tag_utils.apply_rules(df, [ { 'mask': final_mask, 'tags': ['Discard Matters'] } ])
logger.info('Tagged %d cards for Discard Matters', final_mask.sum())
except Exception as e:
logger.error('Error tagging Discard Matters: %s', str(e))
raise
### Life Matters
def tag_for_life_matters(df: pd.DataFrame, color: str) -> None:
"""Tag cards that care about life totals, life gain/loss, and related effects using vectorized operations.
@ -4195,6 +4486,47 @@ def tag_for_aristocrats(df: pd.DataFrame, color: str) -> None:
logger.error(f'Error in tag_for_aristocrats: {str(e)}')
raise
### Bending
def tag_for_bending(df: pd.DataFrame, color: str) -> None:
"""Tag cards for bending-related keywords.
Looks for 'airbend', 'waterbend', 'firebend', 'earthbend' in rules text and
applies tags accordingly.
"""
logger.info(f'Tagging Bending keywords in {color}_cards.csv')
start_time = pd.Timestamp.now()
try:
rules = []
air_mask = tag_utils.create_text_mask(df, 'airbend')
if air_mask.any():
rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters'] })
water_mask = tag_utils.create_text_mask(df, 'waterbend')
if water_mask.any():
rules.append({ 'mask': water_mask, 'tags': ['Waterbending', 'Cost Reduction', 'Big Mana'] })
fire_mask = tag_utils.create_text_mask(df, 'firebend')
if fire_mask.any():
rules.append({ 'mask': fire_mask, 'tags': ['Aggro', 'Combat Matters', 'Firebending', 'Mana Dork', 'Ramp', 'X Spells'] })
earth_mask = tag_utils.create_text_mask(df, 'earthbend')
if earth_mask.any():
rules.append({ 'mask': earth_mask, 'tags': ['Earthbend', 'Lands Matter', 'Landfall'] })
if rules:
tag_utils.apply_rules(df, rules)
total = sum(int(r['mask'].sum()) for r in rules)
logger.info('Tagged %d cards with Bending keywords', total)
else:
logger.info('No Bending keywords found')
duration = (pd.Timestamp.now() - start_time).total_seconds()
logger.info('Completed Bending tagging in %.2fs', duration)
except Exception as e:
logger.error(f'Error tagging Bending keywords: {str(e)}')
raise
## Big Mana
def create_big_mana_cost_mask(df: pd.DataFrame) -> pd.Series:
@ -4766,11 +5098,11 @@ def tag_for_energy(df: pd.DataFrame, color: str) -> None:
energy_patterns = [r'\{e\}', 'energy counter', 'energy counters']
energy_mask = tag_utils.create_text_mask(df, energy_patterns)
# Apply tags via rules engine
# Apply tags via rules engine (also mark as a Resource Engine per request)
tag_utils.apply_rules(df, rules=[
{
'mask': energy_mask,
'tags': ['Energy']
'tags': ['Energy', 'Resource Engine']
}
])