From e31c230e3b381f8033ed3296f77e701d4c5cdaea Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 2 Oct 2025 17:09:07 -0700 Subject: [PATCH] fix(ci): relax headless commander validation --- CHANGELOG.md | 2 +- RELEASE_NOTES_TEMPLATE.md | 3 +- code/headless_runner.py | 86 ++++++++++++++++++++++++++++++++++++ csv_files/testdata/cards.csv | 2 +- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a239dda..d13592d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Preview performance CI check now waits for `/healthz` and retries theme catalog pagination fetches to dodge transient 500s during cold starts. ### Fixed -- _No changes yet._ +- Headless runner commander validation now accepts fuzzy commander prefixes (e.g., "Krenko") so partial names still resolve to eligible entries during automated builds and CI runs. ### Removed - Preview performance GitHub Actions workflow (`.github/workflows/preview-perf-ci.yml`) retired after persistent cold-start failures; run the regression helper script manually as needed. diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index be1f99e..4c83686 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -28,4 +28,5 @@ - Preview performance CI check now waits for service health and retries catalog pagination fetches to smooth out transient 500s on cold boots. ## Fixed -- Documented friendly handling for missing `commander_cards.csv` data during manual QA drills to prevent white-screen failures. \ No newline at end of file +- Documented friendly handling for missing `commander_cards.csv` data during manual QA drills to prevent white-screen failures. +- Headless runner commander validation now accepts fuzzy commander prefixes so automated builds using short commander names keep working. \ No newline at end of file diff --git a/code/headless_runner.py b/code/headless_runner.py index 9bc282e..cc7612d 100644 --- a/code/headless_runner.py +++ b/code/headless_runner.py @@ -3,13 +3,16 @@ from __future__ import annotations import argparse import json import os +import re from dataclasses import asdict, dataclass, field +from functools import lru_cache from typing import Any, Dict, List, Optional, Tuple from deck_builder.builder import DeckBuilder from deck_builder import builder_constants as bc from file_setup.setup import initial_setup from tagging import tagger +from exceptions import CommanderValidationError def _is_stale(file1: str, file2: str) -> bool: """Return True if file2 is missing or older than file1.""" @@ -67,6 +70,85 @@ def _headless_list_owned_files() -> List[str]: return sorted(entries) +def _normalize_commander_name(value: Any) -> str: + return str(value or "").strip().casefold() + + +def _tokenize_commander_name(value: Any) -> List[str]: + normalized = _normalize_commander_name(value) + if not normalized: + return [] + return [token for token in re.split(r"[^a-z0-9]+", normalized) if token] + + +@lru_cache(maxsize=1) +def _load_commander_name_lookup() -> Tuple[set[str], Tuple[str, ...]]: + builder = DeckBuilder( + headless=True, + log_outputs=False, + output_func=lambda *_: None, + input_func=lambda *_: "", + ) + df = builder.load_commander_data() + raw_names: List[str] = [] + for column in ("name", "faceName"): + if column not in df.columns: + continue + series = df[column].dropna().astype(str) + raw_names.extend(series.tolist()) + normalized = { + norm + for norm in (_normalize_commander_name(name) for name in raw_names) + if norm + } + ordered_raw = tuple(dict.fromkeys(raw_names)) + return normalized, ordered_raw + + +def _validate_commander_available(command_name: str) -> None: + normalized = _normalize_commander_name(command_name) + if not normalized: + return + + available, raw_names = _load_commander_name_lookup() + if normalized in available: + return + + query_tokens = _tokenize_commander_name(command_name) + for candidate in raw_names: + candidate_norm = _normalize_commander_name(candidate) + if not candidate_norm: + continue + if candidate_norm.startswith(normalized): + return + candidate_tokens = _tokenize_commander_name(candidate) + if query_tokens and all(token in candidate_tokens for token in query_tokens): + return + + try: + from commander_exclusions import lookup_commander_detail as _lookup_commander_detail # type: ignore[import-not-found] + except ImportError: # pragma: no cover + _lookup_commander_detail = None + + info = _lookup_commander_detail(command_name) if _lookup_commander_detail else None + if info is not None: + primary_face = str(info.get("primary_face") or info.get("name") or "").strip() + eligible_faces = info.get("eligible_faces") + face_hint = ", ".join(str(face) for face in eligible_faces) if isinstance(eligible_faces, list) else "" + message = ( + f"Commander '{command_name}' is no longer available because only a secondary face met commander eligibility." + ) + if primary_face and _normalize_commander_name(primary_face) != normalized: + message += f" Try selecting the front face '{primary_face}' or choose a different commander." + elif face_hint: + message += f" The remaining eligible faces were: {face_hint}." + else: + message += " Choose a different commander whose front face is commander-legal." + raise CommanderValidationError(message, details={"commander": command_name, "reason": info}) + + raise CommanderValidationError(f"Commander not found: {command_name}", details={"commander": command_name}) + + @dataclass class RandomRunConfig: """Runtime options for the headless random build flow.""" @@ -113,6 +195,10 @@ def run( seed: Optional[int | str] = None, ) -> DeckBuilder: """Run a scripted non-interactive deck build and return the DeckBuilder instance.""" + trimmed_commander = (command_name or "").strip() + if trimmed_commander: + _validate_commander_available(trimmed_commander) + owned_prompt_inputs: List[str] = [] owned_files_available = _headless_list_owned_files() if owned_files_available: diff --git a/csv_files/testdata/cards.csv b/csv_files/testdata/cards.csv index dd8513a..e23080b 100644 --- a/csv_files/testdata/cards.csv +++ b/csv_files/testdata/cards.csv @@ -1,6 +1,6 @@ name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side Shock,,,,R,{R},1,Instant,,Deal 2 damage to any target.,,,,[Burn],normal, -Plains,,,,W,,0,Land,,{T}: Add {W}.,,,,[Land],normal,name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side +Plains,,,,W,,0,Land,,{T}: Add {W}.,,,,[Land],normal, Sol Ring,,1,Colorless,,{1},{1},Artifact,,{T}: Add {C}{C}.,,,Mana,Utility,normal, Llanowar Elves,,5000,G,G,{G},{1},Creature,Elf Druid,{T}: Add {G}.,1,1,Mana,Tribal;Ramp,normal, Island,,9999,U,U,,,Land,,{T}: Add {U}.,,,Land,,normal,